mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
refactor
This commit is contained in:
1 parent
5e3474dd59
commit
e4d59a13ba
118 files changed
+9482
-3967
No files matched your search
+12
-1
@@ -2,15 +2,26 @@
|
||||
# Your primary domain (e.g., example.com). Used to derive ALLOWED_HOSTS, CORS, and CSRF settings.
|
||||
DOMAIN="localhost"
|
||||
|
||||
# Set to a long, random string in production (e.g. 50+ chars)
|
||||
# Set to a long, random string in production (e.g. 50+ chars). REQUIRED in prod.
|
||||
SECRET_KEY="django-insecure-replace-this-in-production"
|
||||
|
||||
# Which settings module to load. Use holy_backend.settings.dev locally,
|
||||
# holy_backend.settings.prod in production (Docker compose sets this).
|
||||
DJANGO_SETTINGS_MODULE=holy_backend.settings.dev
|
||||
|
||||
# Set to "false" in production for security and performance
|
||||
DEBUG=true
|
||||
|
||||
# Custom URL for the Django Admin interface (e.g., my-secret-admin/)
|
||||
ADMIN_URL="admin"
|
||||
|
||||
# --- Celery / Redis ---
|
||||
# Broker and result backend for background jobs. Docker compose defaults to the bundled redis service.
|
||||
CELERY_BROKER_URL=redis://redis:6379/0
|
||||
CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||
# Run tasks synchronously (useful for local dev or CI). Unset in production.
|
||||
# CELERY_TASK_ALWAYS_EAGER=true
|
||||
|
||||
# --- Advanced Networking (Optional, derived from DOMAIN if unset) ---
|
||||
# ALLOWED_HOSTS=example.com,another-domain.com
|
||||
# CSRF_TRUSTED_ORIGINS=https://example.com
|
||||
|
||||
+37
-12
@@ -20,11 +20,28 @@ jobs:
|
||||
node-version: '25'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
working-directory: frontend
|
||||
|
||||
- name: Build Frontend
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
working-directory: frontend
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
working-directory: frontend
|
||||
|
||||
- name: Format Check
|
||||
run: npm run format:check
|
||||
working-directory: frontend
|
||||
|
||||
- name: Test
|
||||
run: npm test -- --run
|
||||
working-directory: frontend
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
working-directory: frontend
|
||||
|
||||
@@ -32,6 +49,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
env:
|
||||
SECRET_KEY: "ci-test-key"
|
||||
DEBUG: "false"
|
||||
ALLOWED_HOSTS: "localhost"
|
||||
DJANGO_SETTINGS_MODULE: holy_backend.settings.dev
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -40,24 +62,27 @@ jobs:
|
||||
with:
|
||||
python-version: '3.14.3'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: backend/requirements.txt
|
||||
cache-dependency-path: backend/requirements-dev.txt
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pip install -r requirements.txt
|
||||
run: pip install -r requirements-dev.txt
|
||||
working-directory: backend
|
||||
|
||||
- name: Ruff Lint
|
||||
run: ruff check .
|
||||
working-directory: backend
|
||||
|
||||
- name: Ruff Format Check
|
||||
run: ruff format --check .
|
||||
working-directory: backend
|
||||
|
||||
- name: Django System Check
|
||||
run: python manage.py check
|
||||
working-directory: backend
|
||||
env:
|
||||
SECRET_KEY: "ci-test-key"
|
||||
DEBUG: "false"
|
||||
ALLOWED_HOSTS: "localhost"
|
||||
|
||||
- name: Check Migrations
|
||||
run: python manage.py makemigrations --check --dry-run
|
||||
working-directory: backend
|
||||
env:
|
||||
SECRET_KEY: "ci-test-key"
|
||||
DEBUG: "false"
|
||||
ALLOWED_HOSTS: "localhost"
|
||||
|
||||
- name: Run Tests
|
||||
run: pytest
|
||||
working-directory: backend
|
||||
@@ -1,14 +1,13 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from .models import User, Category, Flavor, Rating, Banner
|
||||
|
||||
from .models import Banner, Category, Flavor, Rating, User
|
||||
|
||||
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
fieldsets = UserAdmin.fieldsets + (
|
||||
(None, {'fields': ('theme',)}),
|
||||
)
|
||||
add_fieldsets = UserAdmin.add_fieldsets + (
|
||||
(None, {'fields': ('theme',)}),
|
||||
)
|
||||
fieldsets = UserAdmin.fieldsets + ((None, {"fields": ("theme",)}),)
|
||||
add_fieldsets = UserAdmin.add_fieldsets + ((None, {"fields": ("theme",)}),)
|
||||
|
||||
|
||||
admin.site.register(User, CustomUserAdmin)
|
||||
admin.site.register(Category)
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@ from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
name = 'api'
|
||||
name = "api"
|
||||
@@ -1,6 +1,8 @@
|
||||
import ssl
|
||||
from django.core.mail.backends.smtp import EmailBackend
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail.backends.smtp import EmailBackend
|
||||
|
||||
|
||||
class InsecureEmailBackend(EmailBackend):
|
||||
"""
|
||||
@@ -8,9 +10,10 @@ class InsecureEmailBackend(EmailBackend):
|
||||
Useful for local development or internal SMTP servers with self-signed certificates.
|
||||
Set EMAIL_SKIP_CERT_VERIFICATION = True in settings to use.
|
||||
"""
|
||||
|
||||
@property
|
||||
def ssl_context(self):
|
||||
if getattr(settings, 'EMAIL_SKIP_CERT_VERIFICATION', False):
|
||||
if getattr(settings, "EMAIL_SKIP_CERT_VERIFICATION", False):
|
||||
# The most robust way to bypass verification for self-signed certs
|
||||
context = ssl._create_unverified_context()
|
||||
return context
|
||||
|
||||
@@ -1,35 +1,40 @@
|
||||
import sqlite3
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Creates a safe, consistent backup of the SQLite database and manages rotation.'
|
||||
help = "Creates a safe, consistent backup of the SQLite database and manages rotation."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--output', type=str, help='Specific output path for the backup file')
|
||||
parser.add_argument('--retention', type=int, default=7, help='Number of days to keep old backups')
|
||||
parser.add_argument('--full', action='store_true', help='Create a full archive including media')
|
||||
parser.add_argument("--output", type=str, help="Specific output path for the backup file")
|
||||
parser.add_argument(
|
||||
"--retention", type=int, default=7, help="Number of days to keep old backups"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--full", action="store_true", help="Create a full archive including media"
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
db_conf = settings.DATABASES['default']
|
||||
if db_conf['ENGINE'] != 'django.db.backends.sqlite3':
|
||||
db_conf = settings.DATABASES["default"]
|
||||
if db_conf["ENGINE"] != "django.db.backends.sqlite3":
|
||||
self.stdout.write(self.style.ERROR("This command only supports SQLite."))
|
||||
return
|
||||
|
||||
db_path = str(db_conf['NAME'])
|
||||
backup_dir = os.path.join(settings.BASE_DIR, 'backups')
|
||||
db_path = str(db_conf["NAME"])
|
||||
backup_dir = os.path.join(settings.BASE_DIR, "backups")
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
# 1. Database Backup
|
||||
if options['output']:
|
||||
db_backup_path = options['output']
|
||||
if options["output"]:
|
||||
db_backup_path = options["output"]
|
||||
else:
|
||||
db_backup_path = os.path.join(backup_dir, f'db_backup_{timestamp}.sqlite3')
|
||||
db_backup_path = os.path.join(backup_dir, f"db_backup_{timestamp}.sqlite3")
|
||||
|
||||
self.stdout.write(f"Cloning database {db_path}...")
|
||||
|
||||
@@ -46,8 +51,9 @@ class Command(BaseCommand):
|
||||
return
|
||||
|
||||
# 2. Full Archive (Optional)
|
||||
if options['full']:
|
||||
if options["full"]:
|
||||
import tarfile
|
||||
|
||||
archive_name = f"full_backup_{timestamp}.tar.gz"
|
||||
archive_path = os.path.join(backup_dir, archive_name)
|
||||
self.stdout.write(f"Creating full archive at {archive_path}...")
|
||||
@@ -62,15 +68,15 @@ class Command(BaseCommand):
|
||||
tar.add(media_dir, arcname="media")
|
||||
|
||||
# Remove the raw sqlite backup after bundling it into the archive
|
||||
if not options['output']:
|
||||
if not options["output"]:
|
||||
os.remove(db_backup_path)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Full archive created successfully."))
|
||||
self.stdout.write(self.style.SUCCESS("Full archive created successfully."))
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f"Archive creation failed: {e}"))
|
||||
|
||||
# 3. Cleanup Old Backups
|
||||
retention_days = options['retention']
|
||||
retention_days = options["retention"]
|
||||
cutoff = datetime.now() - timedelta(days=retention_days)
|
||||
|
||||
self.stdout.write(f"Cleaning up backups older than {retention_days} days...")
|
||||
@@ -81,6 +87,10 @@ class Command(BaseCommand):
|
||||
if file_time < cutoff:
|
||||
try:
|
||||
os.remove(file_path)
|
||||
self.stdout.write(self.style.SUCCESS(f" -> Deleted old backup: {filename}"))
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" -> Deleted old backup: {filename}")
|
||||
)
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.WARNING(f" -> Failed to delete {filename}: {e}"))
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f" -> Failed to delete {filename}: {e}")
|
||||
)
|
||||
@@ -1,33 +1,38 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from api.models import Flavor, Rating
|
||||
from django.db.models import Count
|
||||
|
||||
from api.models import Flavor, Rating
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Identifies and merges duplicate flavors (same name and category)'
|
||||
help = "Identifies and merges duplicate flavors (same name and category)"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Find duplicates based on name and category
|
||||
duplicates = Flavor.objects.values('name', 'category').annotate(
|
||||
name_count=Count('id')
|
||||
).filter(name_count__gt=1)
|
||||
duplicates = (
|
||||
Flavor.objects.values("name", "category")
|
||||
.annotate(name_count=Count("id"))
|
||||
.filter(name_count__gt=1)
|
||||
)
|
||||
|
||||
self.stdout.write(f"Found {duplicates.count()} sets of duplicates.")
|
||||
|
||||
for dup in duplicates:
|
||||
name = dup['name']
|
||||
category_id = dup['category']
|
||||
name = dup["name"]
|
||||
category_id = dup["category"]
|
||||
|
||||
# Get all flavors in this set, ordered by having an external_id first, then by date
|
||||
flavor_set = Flavor.objects.filter(
|
||||
name=name,
|
||||
category_id=category_id
|
||||
).order_by('-external_id', 'created_at')
|
||||
flavor_set = Flavor.objects.filter(name=name, category_id=category_id).order_by(
|
||||
"-external_id", "created_at"
|
||||
)
|
||||
|
||||
# Keep the first one, merge others into it
|
||||
keep = flavor_set[0]
|
||||
others = flavor_set[1:]
|
||||
|
||||
self.stdout.write(f"Merging {len(others)} duplicates into '{keep.name}' (ID: {keep.id}, Ext: {keep.external_id})")
|
||||
self.stdout.write(
|
||||
f"Merging {len(others)} duplicates into '{keep.name}' (ID: {keep.id}, Ext: {keep.external_id})"
|
||||
)
|
||||
|
||||
for other in others:
|
||||
# Re-assign ratings
|
||||
|
||||
@@ -1,45 +1,48 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from api.models import Banner
|
||||
import os
|
||||
import json
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from api.models import Banner
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Seeds banner models and missing settings from JSON files in backend/banners/'
|
||||
help = "Seeds banner models and missing settings from JSON files in backend/banners/"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Determine the banners directory relative to the project root
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||
banners_dir = os.path.join(project_root, 'banners')
|
||||
banners_dir = os.path.join(project_root, "banners")
|
||||
|
||||
if not os.path.exists(banners_dir):
|
||||
self.stdout.write(self.style.WARNING(f"Banners directory not found at {banners_dir}"))
|
||||
return
|
||||
|
||||
for filename in os.listdir(banners_dir):
|
||||
if filename.endswith('.json'):
|
||||
if filename.endswith(".json"):
|
||||
filepath = os.path.join(banners_dir, filename)
|
||||
try:
|
||||
with open(filepath, 'r') as f:
|
||||
with open(filepath) as f:
|
||||
data = json.load(f)
|
||||
|
||||
slug = data.get('slug')
|
||||
slug = data.get("slug")
|
||||
if not slug:
|
||||
continue
|
||||
|
||||
banner, created = Banner.objects.get_or_create(
|
||||
slug=slug,
|
||||
defaults={
|
||||
'name': data.get('name', slug),
|
||||
'description': data.get('description', ''),
|
||||
'settings': data.get('settings', {})
|
||||
}
|
||||
"name": data.get("name", slug),
|
||||
"description": data.get("description", ""),
|
||||
"settings": data.get("settings", {}),
|
||||
},
|
||||
)
|
||||
|
||||
if not created:
|
||||
# Sync missing settings from JSON to DB without overwriting existing keys
|
||||
db_settings = banner.settings or {}
|
||||
json_settings = data.get('settings', {})
|
||||
json_schema = data.get('schema', [])
|
||||
json_settings = data.get("settings", {})
|
||||
json_schema = data.get("schema", [])
|
||||
|
||||
updated = False
|
||||
# Sync missing settings
|
||||
@@ -56,9 +59,17 @@ class Command(BaseCommand):
|
||||
if updated:
|
||||
banner.settings = db_settings
|
||||
banner.save()
|
||||
self.stdout.write(self.style.SUCCESS(f"Synced new settings for banner '{slug}' from {filename}"))
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Synced new settings for banner '{slug}' from {filename}"
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(f"Created new banner '{slug}' from {filename}"))
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Created new banner '{slug}' from {filename}")
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f"Failed to sync banner from {filename}: {e}"))
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f"Failed to sync banner from {filename}: {e}")
|
||||
)
|
||||
@@ -1,41 +1,45 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
import requests
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.files import File
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils.text import slugify
|
||||
|
||||
from api.models import Category, Flavor
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Adds legacy flavors from JSON files in the legacy/ folder'
|
||||
help = "Adds legacy flavors from JSON files in the legacy/ folder"
|
||||
|
||||
def download_image(self, url, flavor_name):
|
||||
if not url:
|
||||
return None
|
||||
try:
|
||||
safe_name = slugify(flavor_name)
|
||||
ext = url.split('.')[-1].split('?')[0].lower()
|
||||
if ext not in ['jpg', 'jpeg', 'png', 'webp', 'gif']:
|
||||
ext = 'png'
|
||||
ext = url.split(".")[-1].split("?")[0].lower()
|
||||
if ext not in ["jpg", "jpeg", "png", "webp", "gif"]:
|
||||
ext = "png"
|
||||
|
||||
filename = f"legacy_{safe_name}.{ext}"
|
||||
filepath = os.path.join(settings.MEDIA_ROOT, 'flavors', filename)
|
||||
filepath = os.path.join(settings.MEDIA_ROOT, "flavors", filename)
|
||||
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
|
||||
response = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=10)
|
||||
response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
|
||||
with open(filepath, 'wb') as f:
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(response.content)
|
||||
|
||||
return f"flavors/{filename}"
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.WARNING(f"Failed to download image for {flavor_name}: {e}"))
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f"Failed to download image for {flavor_name}: {e}")
|
||||
)
|
||||
return None
|
||||
|
||||
def handle(self, *args, **options):
|
||||
@@ -43,22 +47,30 @@ class Command(BaseCommand):
|
||||
# Instead, we update existing entries.
|
||||
|
||||
cat_map = {
|
||||
'energy': Category.objects.get_or_create(name='Energy', defaults={'slug': 'energy'})[0],
|
||||
'hydration': Category.objects.get_or_create(name='Hydration', defaults={'slug': 'hydration'})[0],
|
||||
'iced-tea': Category.objects.get_or_create(name='Iced Tea', defaults={'slug': 'iced-tea'})[0],
|
||||
'milkshake': Category.objects.get_or_create(name='Milkshake', defaults={'slug': 'milkshake'})[0],
|
||||
'packs': Category.objects.get_or_create(name='Packs and other', defaults={'slug': 'packs-and-other'})[0],
|
||||
"energy": Category.objects.get_or_create(name="Energy", defaults={"slug": "energy"})[0],
|
||||
"hydration": Category.objects.get_or_create(
|
||||
name="Hydration", defaults={"slug": "hydration"}
|
||||
)[0],
|
||||
"iced-tea": Category.objects.get_or_create(
|
||||
name="Iced Tea", defaults={"slug": "iced-tea"}
|
||||
)[0],
|
||||
"milkshake": Category.objects.get_or_create(
|
||||
name="Milkshake", defaults={"slug": "milkshake"}
|
||||
)[0],
|
||||
"packs": Category.objects.get_or_create(
|
||||
name="Packs and other", defaults={"slug": "packs-and-other"}
|
||||
)[0],
|
||||
}
|
||||
|
||||
legacy_dir = os.path.join(settings.BASE_DIR, 'legacy')
|
||||
legacy_dir = os.path.join(settings.BASE_DIR, "legacy")
|
||||
if not os.path.exists(legacy_dir):
|
||||
legacy_dir = os.path.join(settings.BASE_DIR, '..', 'legacy')
|
||||
legacy_dir = os.path.join(settings.BASE_DIR, "..", "legacy")
|
||||
|
||||
if not os.path.exists(legacy_dir):
|
||||
self.stdout.write(self.style.ERROR(f"Legacy directory not found."))
|
||||
self.stdout.write(self.style.ERROR("Legacy directory not found."))
|
||||
return
|
||||
|
||||
json_files = [f for f in os.listdir(legacy_dir) if f.endswith('.json')]
|
||||
json_files = [f for f in os.listdir(legacy_dir) if f.endswith(".json")]
|
||||
processed_count = 0
|
||||
|
||||
for json_file in json_files:
|
||||
@@ -66,26 +78,26 @@ class Command(BaseCommand):
|
||||
file_lower = json_file.lower()
|
||||
|
||||
# Determine category from filename
|
||||
if 'energy' in file_lower:
|
||||
file_cat = cat_map['energy']
|
||||
elif 'iced_tea' in file_lower or 'iced-tea' in file_lower:
|
||||
file_cat = cat_map['iced-tea']
|
||||
elif 'hydration' in file_lower:
|
||||
file_cat = cat_map['hydration']
|
||||
elif 'milkshake' in file_lower:
|
||||
file_cat = cat_map['milkshake']
|
||||
if "energy" in file_lower:
|
||||
file_cat = cat_map["energy"]
|
||||
elif "iced_tea" in file_lower or "iced-tea" in file_lower:
|
||||
file_cat = cat_map["iced-tea"]
|
||||
elif "hydration" in file_lower:
|
||||
file_cat = cat_map["hydration"]
|
||||
elif "milkshake" in file_lower:
|
||||
file_cat = cat_map["milkshake"]
|
||||
else:
|
||||
file_cat = None
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
with open(file_path, encoding="utf-8") as f:
|
||||
data_list = json.load(f)
|
||||
|
||||
for data in data_list:
|
||||
name = data.get('Name')
|
||||
desc = data.get('Beschreibung')
|
||||
taste = data.get('Geschmack')
|
||||
status_text = data.get('Status')
|
||||
image_url = data.get('Bild_URL')
|
||||
name = data.get("Name")
|
||||
desc = data.get("Beschreibung")
|
||||
taste = data.get("Geschmack")
|
||||
status_text = data.get("Status")
|
||||
image_url = data.get("Bild_URL")
|
||||
|
||||
full_desc = f"**Geschmack:** {taste}\n\n{desc}\n\n**Status:** {status_text}"
|
||||
|
||||
@@ -94,15 +106,27 @@ class Command(BaseCommand):
|
||||
if not cat:
|
||||
# Fallback to name/desc detection if filename was ambiguous
|
||||
if "hydration" in name.lower() or "hydration" in desc.lower():
|
||||
cat = cat_map['hydration']
|
||||
cat = cat_map["hydration"]
|
||||
elif "iced tea" in name.lower() or "eistee" in name.lower():
|
||||
cat = cat_map['iced-tea']
|
||||
cat = cat_map["iced-tea"]
|
||||
elif "milkshake" in name.lower():
|
||||
cat = cat_map['milkshake']
|
||||
elif any(k in name.lower() for k in ['bundle', 'set', 'box', 'probe', 'sample', 'taster', 'shaker', 'starter']):
|
||||
cat = cat_map['packs']
|
||||
cat = cat_map["milkshake"]
|
||||
elif any(
|
||||
k in name.lower()
|
||||
for k in [
|
||||
"bundle",
|
||||
"set",
|
||||
"box",
|
||||
"probe",
|
||||
"sample",
|
||||
"taster",
|
||||
"shaker",
|
||||
"starter",
|
||||
]
|
||||
):
|
||||
cat = cat_map["packs"]
|
||||
else:
|
||||
cat = cat_map['energy'] # Ultimate fallback
|
||||
cat = cat_map["energy"] # Ultimate fallback
|
||||
|
||||
# Try to find existing flavor by name and category regardless of legacy status
|
||||
flavor = Flavor.objects.filter(name__iexact=name, category=cat).first()
|
||||
@@ -113,12 +137,7 @@ class Command(BaseCommand):
|
||||
continue
|
||||
|
||||
if not flavor:
|
||||
flavor = Flavor(
|
||||
name=name,
|
||||
category=cat,
|
||||
is_legacy=True,
|
||||
external_id=None
|
||||
)
|
||||
flavor = Flavor(name=name, category=cat, is_legacy=True, external_id=None)
|
||||
self.stdout.write(f"Adding new legacy flavor: {name}")
|
||||
else:
|
||||
self.stdout.write(f"Updating legacy flavor: {name}")
|
||||
@@ -145,4 +164,6 @@ class Command(BaseCommand):
|
||||
flavor.save()
|
||||
processed_count += 1
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Finished! Processed {processed_count} legacy flavors."))
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Finished! Processed {processed_count} legacy flavors.")
|
||||
)
|
||||
@@ -1,21 +1,22 @@
|
||||
import json
|
||||
import requests
|
||||
import re
|
||||
import os
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.files import File
|
||||
import re
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils.text import slugify
|
||||
|
||||
from api.models import Category, Flavor
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Syncs flavors from Holy Energy Shopify API'
|
||||
help = "Syncs flavors from Holy Energy Shopify API"
|
||||
|
||||
def clean_html(self, raw_html):
|
||||
if not raw_html:
|
||||
return ""
|
||||
cleanr = re.compile('<.*?>')
|
||||
cleantext = re.sub(cleanr, '', raw_html)
|
||||
cleanr = re.compile("<.*?>")
|
||||
cleantext = re.sub(cleanr, "", raw_html)
|
||||
return cleantext.strip()
|
||||
|
||||
def download_image(self, url, flavor_name):
|
||||
@@ -23,27 +24,29 @@ class Command(BaseCommand):
|
||||
return None
|
||||
try:
|
||||
safe_name = slugify(flavor_name)
|
||||
ext = url.split('.')[-1].split('?')[0].lower()
|
||||
if ext not in ['jpg', 'jpeg', 'png', 'webp', 'gif']:
|
||||
ext = 'png'
|
||||
ext = url.split(".")[-1].split("?")[0].lower()
|
||||
if ext not in ["jpg", "jpeg", "png", "webp", "gif"]:
|
||||
ext = "png"
|
||||
|
||||
filename = f"{safe_name}.{ext}"
|
||||
filepath = os.path.join(settings.MEDIA_ROOT, 'flavors', filename)
|
||||
filepath = os.path.join(settings.MEDIA_ROOT, "flavors", filename)
|
||||
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
|
||||
response = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=10)
|
||||
response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
|
||||
with open(filepath, 'wb') as f:
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(response.content)
|
||||
|
||||
return f"flavors/{filename}"
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.WARNING(f"Failed to download image for {flavor_name}: {e}"))
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f"Failed to download image for {flavor_name}: {e}")
|
||||
)
|
||||
return None
|
||||
|
||||
def handle(self, *args, **options):
|
||||
@@ -51,22 +54,30 @@ class Command(BaseCommand):
|
||||
self.stdout.write(f"Fetching from {url}...")
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=15)
|
||||
response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=15)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f"Failed to fetch data: {e}"))
|
||||
return
|
||||
|
||||
products = data.get('products', [])
|
||||
products = data.get("products", [])
|
||||
self.stdout.write(f"Found {len(products)} products.")
|
||||
|
||||
cat_map = {
|
||||
'energy': Category.objects.get_or_create(name='Energy', defaults={'slug': 'energy'})[0],
|
||||
'hydration': Category.objects.get_or_create(name='Hydration', defaults={'slug': 'hydration'})[0],
|
||||
'iced-tea': Category.objects.get_or_create(name='Iced Tea', defaults={'slug': 'iced-tea'})[0],
|
||||
'milkshake': Category.objects.get_or_create(name='Milkshake', defaults={'slug': 'milkshake'})[0],
|
||||
'packs': Category.objects.get_or_create(name='Packs and other', defaults={'slug': 'packs-and-other'})[0],
|
||||
"energy": Category.objects.get_or_create(name="Energy", defaults={"slug": "energy"})[0],
|
||||
"hydration": Category.objects.get_or_create(
|
||||
name="Hydration", defaults={"slug": "hydration"}
|
||||
)[0],
|
||||
"iced-tea": Category.objects.get_or_create(
|
||||
name="Iced Tea", defaults={"slug": "iced-tea"}
|
||||
)[0],
|
||||
"milkshake": Category.objects.get_or_create(
|
||||
name="Milkshake", defaults={"slug": "milkshake"}
|
||||
)[0],
|
||||
"packs": Category.objects.get_or_create(
|
||||
name="Packs and other", defaults={"slug": "packs-and-other"}
|
||||
)[0],
|
||||
}
|
||||
|
||||
created_count = 0
|
||||
@@ -74,50 +85,60 @@ class Command(BaseCommand):
|
||||
synced_external_ids = []
|
||||
|
||||
for p in products:
|
||||
title = p.get('title', '').strip()
|
||||
tags = p.get('tags', [])
|
||||
title = p.get("title", "").strip()
|
||||
tags = p.get("tags", [])
|
||||
if isinstance(tags, str):
|
||||
tags = [t.strip() for t in tags.split(',')]
|
||||
tags = [t.strip() for t in tags.split(",")]
|
||||
|
||||
tags_lower = [t.lower() for t in tags]
|
||||
title_lower = title.lower()
|
||||
|
||||
is_pack = False
|
||||
pack_keywords = ['bundle', 'set', 'box', 'probe', 'sample', 'taster', 'starter', 'collection', 'probier']
|
||||
pack_keywords = [
|
||||
"bundle",
|
||||
"set",
|
||||
"box",
|
||||
"probe",
|
||||
"sample",
|
||||
"taster",
|
||||
"starter",
|
||||
"collection",
|
||||
"probier",
|
||||
]
|
||||
if any(k in title_lower for k in pack_keywords):
|
||||
is_pack = True
|
||||
if 'shaker' in title_lower or 'merch' in tags_lower:
|
||||
if "shaker" in title_lower or "merch" in tags_lower:
|
||||
is_pack = True
|
||||
|
||||
category = None
|
||||
if is_pack:
|
||||
category = cat_map['packs']
|
||||
elif 'holy energy' in tags_lower or 'energy' in tags_lower:
|
||||
category = cat_map['energy']
|
||||
elif 'holy hydration' in tags_lower or 'hydration' in tags_lower:
|
||||
category = cat_map['hydration']
|
||||
elif 'holy iced tea' in tags_lower or 'iced tea' in tags_lower:
|
||||
category = cat_map['iced-tea']
|
||||
elif 'milkshake' in tags_lower:
|
||||
category = cat_map['milkshake']
|
||||
category = cat_map["packs"]
|
||||
elif "holy energy" in tags_lower or "energy" in tags_lower:
|
||||
category = cat_map["energy"]
|
||||
elif "holy hydration" in tags_lower or "hydration" in tags_lower:
|
||||
category = cat_map["hydration"]
|
||||
elif "holy iced tea" in tags_lower or "iced tea" in tags_lower:
|
||||
category = cat_map["iced-tea"]
|
||||
elif "milkshake" in tags_lower:
|
||||
category = cat_map["milkshake"]
|
||||
|
||||
if not category:
|
||||
if 'shaker' in tags_lower:
|
||||
category = cat_map['packs']
|
||||
if "shaker" in tags_lower:
|
||||
category = cat_map["packs"]
|
||||
else:
|
||||
continue
|
||||
|
||||
variants = p.get('variants', [])
|
||||
is_available = any(v.get('available') for v in variants)
|
||||
images = p.get('images', [])
|
||||
image_url = images[0].get('src') if images else None
|
||||
description = self.clean_html(p.get('body_html', ''))
|
||||
handle = p.get('handle')
|
||||
variants = p.get("variants", [])
|
||||
is_available = any(v.get("available") for v in variants)
|
||||
images = p.get("images", [])
|
||||
image_url = images[0].get("src") if images else None
|
||||
description = self.clean_html(p.get("body_html", ""))
|
||||
handle = p.get("handle")
|
||||
shop_url = f"https://weareholy.com/products/{handle}" if handle else None
|
||||
|
||||
# Find or create - be very specific to avoid duplicates
|
||||
# 1. Try external_id
|
||||
flavor = Flavor.objects.filter(external_id=p['id']).first()
|
||||
flavor = Flavor.objects.filter(external_id=p["id"]).first()
|
||||
|
||||
if not flavor:
|
||||
# 2. Try exact name match in category
|
||||
@@ -130,20 +151,25 @@ class Command(BaseCommand):
|
||||
flavor = Flavor.objects.filter(name__iexact=title, category=category).first()
|
||||
|
||||
if not flavor:
|
||||
flavor = Flavor(external_id=p['id'], name=title, category=category, is_legacy=False)
|
||||
flavor = Flavor(external_id=p["id"], name=title, category=category, is_legacy=False)
|
||||
created_count += 1
|
||||
else:
|
||||
updated_count += 1
|
||||
# Ensure external_id is updated if it was missing
|
||||
if not flavor.external_id:
|
||||
flavor.external_id = p['id']
|
||||
flavor.external_id = p["id"]
|
||||
|
||||
# Reactivate the flavor if it was previously marked as legacy
|
||||
if flavor.is_legacy:
|
||||
flavor.is_legacy = False
|
||||
|
||||
# Ensure all synced fields are accurately reflected
|
||||
if flavor.name != title and not Flavor.objects.filter(name=title, category=category).exclude(pk=flavor.pk).exists():
|
||||
if (
|
||||
flavor.name != title
|
||||
and not Flavor.objects.filter(name=title, category=category)
|
||||
.exclude(pk=flavor.pk)
|
||||
.exists()
|
||||
):
|
||||
flavor.name = title
|
||||
|
||||
flavor.description = description
|
||||
@@ -165,11 +191,21 @@ class Command(BaseCommand):
|
||||
flavor.image = rel_path
|
||||
self.stdout.write(f"Downloaded image for: {title}")
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING(f"Failed to ensure image for: {title}"))
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f"Failed to ensure image for: {title}")
|
||||
)
|
||||
|
||||
flavor.image_url = image_url
|
||||
flavor.save()
|
||||
synced_external_ids.append(p['id'])
|
||||
synced_external_ids.append(p["id"])
|
||||
|
||||
discontinued_count = Flavor.objects.filter(external_id__isnull=False).exclude(external_id__in=synced_external_ids).update(is_available=False)
|
||||
self.stdout.write(self.style.SUCCESS(f"Finished! Created: {created_count}, Updated: {updated_count}, Marked Unavailable: {discontinued_count}"))
|
||||
discontinued_count = (
|
||||
Flavor.objects.filter(external_id__isnull=False)
|
||||
.exclude(external_id__in=synced_external_ids)
|
||||
.update(is_available=False)
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Finished! Created: {created_count}, Updated: {updated_count}, Marked Unavailable: {discontinued_count}"
|
||||
)
|
||||
)
|
||||
@@ -1,81 +0,0 @@
|
||||
import time
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.management import call_command
|
||||
from django.utils import timezone
|
||||
from django.db import models
|
||||
from api.models import Job
|
||||
from io import StringIO
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Background worker to process scheduled and on-demand jobs.'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write(self.style.SUCCESS("Starting Holy Archive Worker..."))
|
||||
|
||||
# Ensure all core jobs exist in the DB
|
||||
for job_type, label in Job.JOB_TYPES:
|
||||
Job.objects.get_or_create(name=job_type)
|
||||
|
||||
while True:
|
||||
# 1. Find jobs that are either explicitly set to 'pending' (manual trigger)
|
||||
# or those where next_run has passed.
|
||||
now = timezone.now()
|
||||
jobs_to_run = Job.objects.filter(
|
||||
~models.Q(status='running')
|
||||
).filter(
|
||||
# Run if pending OR (next_run is set AND has passed)
|
||||
models.Q(status='pending') |
|
||||
models.Q(next_run__lte=now, interval_hours__gt=0)
|
||||
)
|
||||
|
||||
for job in jobs_to_run:
|
||||
self.run_job(job)
|
||||
|
||||
time.sleep(30) # Check every 30 seconds
|
||||
|
||||
def run_job(self, job):
|
||||
self.stdout.write(f"Executing Job: {job.get_name_display()}...")
|
||||
job.status = 'running'
|
||||
job.last_run = timezone.now()
|
||||
job.last_output = "" # Reset output for new run
|
||||
job.save()
|
||||
|
||||
# Capture management command output
|
||||
class LiveStream(StringIO):
|
||||
def write(self, s):
|
||||
super().write(s)
|
||||
# Periodically update the DB with current output
|
||||
job.last_output = self.getvalue()
|
||||
job.save(update_fields=['last_output'])
|
||||
|
||||
out = LiveStream()
|
||||
error = ""
|
||||
|
||||
try:
|
||||
if job.name == 'sync_flavors':
|
||||
call_command('sync_flavors', stdout=out)
|
||||
elif job.name == 'cleanup_duplicates':
|
||||
call_command('cleanup_duplicates', stdout=out)
|
||||
elif job.name == 'backup_db':
|
||||
call_command('backup_db', '--full', stdout=out)
|
||||
elif job.name == 'seed_legacy':
|
||||
call_command('seed_legacy_flavors', stdout=out)
|
||||
|
||||
job.status = 'completed'
|
||||
except Exception as e:
|
||||
job.status = 'failed'
|
||||
error = str(e)
|
||||
self.stdout.write(self.style.ERROR(f"Job failed: {error}"))
|
||||
|
||||
# Update scheduling for next run if interval is set
|
||||
if job.interval_hours > 0:
|
||||
job.next_run = timezone.now() + timedelta(hours=job.interval_hours)
|
||||
else:
|
||||
job.next_run = None
|
||||
|
||||
job.last_output = out.getvalue()
|
||||
job.error_message = error
|
||||
job.save()
|
||||
self.stdout.write(self.style.SUCCESS(f"Job finished: {job.status}"))
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.4 on 2026-04-11 00:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0029_alter_job_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='flavor',
|
||||
name='image_urls',
|
||||
field=models.JSONField(blank=True, default=list),
|
||||
),
|
||||
]
|
||||
@@ -1,266 +0,0 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
|
||||
class User(AbstractUser):
|
||||
THEME_CHOICES = [
|
||||
('holy_light', 'Holy Light'),
|
||||
('holy_dark', 'Holy Dark'),
|
||||
('latte', 'Catppuccin Latte'),
|
||||
('frappe', 'Catppuccin Frappé'),
|
||||
('macchiato', 'Catppuccin Macchiato'),
|
||||
('mocha', 'Catppuccin Mocha'),
|
||||
('pink_pastel', 'Pink Pastel'),
|
||||
('mint_pastel', 'Mint Pastel'),
|
||||
('lavender_pastel', 'Lavender Pastel'),
|
||||
('dracula', 'Dracula'),
|
||||
('nord', 'Nordic Frost'),
|
||||
('gruvbox', 'Gruvbox Retro'),
|
||||
('oceanic', 'Oceanic Deep'),
|
||||
('t0p_sai', 'SAI (T0P)'),
|
||||
('t0p_trench', 'Trench (T0P)'),
|
||||
('t0p_blurryface', 'Blurryface (T0P)'),
|
||||
('t0p_clancy', 'Clancy (T0P)'),
|
||||
]
|
||||
LANGUAGE_CHOICES = [
|
||||
('en', 'English'),
|
||||
('de', 'Deutsch'),
|
||||
]
|
||||
theme = models.CharField(max_length=20, choices=THEME_CHOICES, default='holy_light')
|
||||
language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default='en')
|
||||
avatar = models.ImageField(upload_to='avatars/', blank=True, null=True)
|
||||
pending_email = models.EmailField(max_length=254, blank=True, null=True)
|
||||
email_confirmation_code = models.CharField(max_length=6, blank=True, null=True)
|
||||
following = models.ManyToManyField('self', symmetrical=False, related_name='followers', blank=True)
|
||||
selected_banner = models.ForeignKey('Banner', on_delete=models.SET_NULL, null=True, blank=True, related_name='users')
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
if self.avatar:
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(self.avatar.path)
|
||||
if img.height > 256 or img.width > 256 or img.format != 'JPEG':
|
||||
output_size = (256, 256)
|
||||
img.thumbnail(output_size)
|
||||
if img.mode in ("RGBA", "P"):
|
||||
img = img.convert("RGB")
|
||||
img.save(self.avatar.path, 'JPEG', quality=90)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
|
||||
class Category(models.Model):
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Categories"
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Flavor(models.Model):
|
||||
category = models.ForeignKey(Category, related_name='flavors', on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=100) # Removed unique=True here
|
||||
description = models.TextField(blank=True)
|
||||
image_url = models.URLField(max_length=1000, blank=True, null=True)
|
||||
image = models.ImageField(upload_to='flavors/', blank=True, null=True)
|
||||
shop_url = models.URLField(max_length=500, blank=True, null=True)
|
||||
is_available = models.BooleanField(default=True)
|
||||
is_legacy = models.BooleanField(default=False)
|
||||
external_id = models.BigIntegerField(unique=True, null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
# This allows "Erdbeere" in Iced Tea and "Erdbeere" in Milkshake
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['name', 'category'], name='unique_flavor_name_per_category')
|
||||
]
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.category.name})"
|
||||
|
||||
@property
|
||||
def get_average_rating(self):
|
||||
ratings = self.ratings.all()
|
||||
if ratings.exists():
|
||||
return sum(r.score for r in ratings) / ratings.count()
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def cached_image_url(self):
|
||||
if self.image:
|
||||
return self.image.url
|
||||
return self.image_url
|
||||
|
||||
class Rating(models.Model):
|
||||
user = models.ForeignKey(User, related_name='ratings', on_delete=models.CASCADE)
|
||||
flavor = models.ForeignKey(Flavor, related_name='ratings', on_delete=models.CASCADE)
|
||||
score = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(10)])
|
||||
comment = models.TextField(blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('user', 'flavor')
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} - {self.flavor.name}: {self.score}"
|
||||
|
||||
class Reply(models.Model):
|
||||
user = models.ForeignKey(User, related_name='replies', on_delete=models.CASCADE)
|
||||
rating = models.ForeignKey(Rating, related_name='replies', on_delete=models.CASCADE)
|
||||
text = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"Reply by {self.user.username} on {self.rating.id}"
|
||||
|
||||
class Notification(models.Model):
|
||||
TYPE_CHOICES = [
|
||||
('reply', 'Reply'),
|
||||
('mention', 'Mention'),
|
||||
('follow', 'Follow'),
|
||||
('ticket_new', 'New Ticket'),
|
||||
('ticket_reply', 'Ticket Reply'),
|
||||
('profile_comment', 'Profile Comment'),
|
||||
]
|
||||
recipient = models.ForeignKey(User, related_name='notifications', on_delete=models.CASCADE)
|
||||
actor = models.ForeignKey(User, related_name='actions', on_delete=models.CASCADE)
|
||||
notification_type = models.CharField(max_length=20, choices=TYPE_CHOICES)
|
||||
rating = models.ForeignKey(Rating, on_delete=models.CASCADE, null=True, blank=True)
|
||||
reply = models.ForeignKey(Reply, on_delete=models.CASCADE, null=True, blank=True)
|
||||
ticket = models.ForeignKey('Ticket', on_delete=models.CASCADE, null=True, blank=True)
|
||||
profile_comment = models.ForeignKey('ProfileComment', on_delete=models.CASCADE, null=True, blank=True)
|
||||
is_read = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.notification_type} for {self.recipient.username}"
|
||||
|
||||
class UserIP(models.Model):
|
||||
user = models.ForeignKey(User, related_name='ips', on_delete=models.CASCADE)
|
||||
ip_address = models.GenericIPAddressField()
|
||||
last_login = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('user', 'ip_address')
|
||||
ordering = ['-last_login']
|
||||
|
||||
class Ticket(models.Model):
|
||||
STATUS_CHOICES = [
|
||||
('open', 'Open'),
|
||||
('in_progress', 'In Progress'),
|
||||
('resolved', 'Resolved'),
|
||||
('closed', 'Closed'),
|
||||
]
|
||||
user = models.ForeignKey(User, related_name='tickets', on_delete=models.CASCADE)
|
||||
subject = models.CharField(max_length=200)
|
||||
description = models.TextField()
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='open')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-updated_at']
|
||||
|
||||
class TicketMessage(models.Model):
|
||||
ticket = models.ForeignKey(Ticket, related_name='messages', on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
text = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['created_at']
|
||||
|
||||
class ProfileComment(models.Model):
|
||||
profile_owner = models.ForeignKey(User, related_name='profile_comments', on_delete=models.CASCADE)
|
||||
author = models.ForeignKey(User, related_name='authored_profile_comments', on_delete=models.CASCADE)
|
||||
text = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
class Banner(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
slug = models.SlugField(unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
is_active = models.BooleanField(default=False, help_text="Designates this as the global default banner.")
|
||||
is_enabled = models.BooleanField(default=True, help_text="Allows users to select this banner.")
|
||||
settings = models.JSONField(default=dict, blank=True)
|
||||
schema = models.JSONField(default=list, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.is_active:
|
||||
# Set all others to inactive
|
||||
Banner.objects.filter(is_active=True).exclude(pk=self.pk).update(is_active=False)
|
||||
elif not Banner.objects.filter(is_active=True).exclude(pk=self.pk).exists():
|
||||
# If this was the only active banner, don't allow deactivating it
|
||||
# unless there's another one to take its place.
|
||||
self.is_active = True
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class SystemConfig(models.Model):
|
||||
"""Singleton model for application-wide settings editable via UI."""
|
||||
site_name = models.CharField(max_length=100, default="Holy Flavors Archive")
|
||||
maintenance_mode = models.BooleanField(default=False)
|
||||
allow_new_signups = models.BooleanField(default=True)
|
||||
require_email_verification = models.BooleanField(default=True)
|
||||
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.pk = 1 # Force singleton
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def get_solo(cls):
|
||||
obj, _ = cls.objects.get_or_create(pk=1)
|
||||
return obj
|
||||
|
||||
class Meta:
|
||||
verbose_name = "System Configuration"
|
||||
|
||||
class Job(models.Model):
|
||||
"""Tracks background jobs, their status and scheduling."""
|
||||
JOB_TYPES = [
|
||||
('sync_flavors', 'Sync Holy Flavors'),
|
||||
('cleanup_duplicates', 'Cleanup Duplicate Flavors'),
|
||||
('backup_db', 'Database Backup'),
|
||||
('seed_legacy', 'Seed Legacy Data'),
|
||||
]
|
||||
STATUS_CHOICES = [
|
||||
('pending', 'Pending'),
|
||||
('running', 'Running'),
|
||||
('completed', 'Completed'),
|
||||
('failed', 'Failed'),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=100, choices=JOB_TYPES)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
|
||||
last_run = models.DateTimeField(null=True, blank=True)
|
||||
next_run = models.DateTimeField(null=True, blank=True)
|
||||
interval_hours = models.IntegerField(default=0, help_text="Set to 0 to disable periodic run.")
|
||||
|
||||
last_output = models.TextField(blank=True)
|
||||
error_message = models.TextField(blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_name_display()} ({self.status})"
|
||||
@@ -0,0 +1,22 @@
|
||||
from api.models.flavor import Category, Flavor
|
||||
from api.models.rating import Rating, Reply
|
||||
from api.models.social import Notification, ProfileComment
|
||||
from api.models.support import Ticket, TicketMessage
|
||||
from api.models.system import Banner, Job, SystemConfig
|
||||
from api.models.user import User, UserIP
|
||||
|
||||
__all__ = [
|
||||
"Banner",
|
||||
"Category",
|
||||
"Flavor",
|
||||
"Job",
|
||||
"Notification",
|
||||
"ProfileComment",
|
||||
"Rating",
|
||||
"Reply",
|
||||
"SystemConfig",
|
||||
"Ticket",
|
||||
"TicketMessage",
|
||||
"User",
|
||||
"UserIP",
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
from django.db import models
|
||||
from django.db.models import Avg
|
||||
|
||||
|
||||
class Category(models.Model):
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Categories"
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class Flavor(models.Model):
|
||||
category = models.ForeignKey(Category, related_name="flavors", on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=100)
|
||||
description = models.TextField(blank=True)
|
||||
image_url = models.URLField(max_length=1000, blank=True, null=True)
|
||||
image_urls = models.JSONField(blank=True, default=list)
|
||||
image = models.ImageField(upload_to="flavors/", blank=True, null=True)
|
||||
shop_url = models.URLField(max_length=500, blank=True, null=True)
|
||||
is_available = models.BooleanField(default=True)
|
||||
is_legacy = models.BooleanField(default=False)
|
||||
external_id = models.BigIntegerField(unique=True, null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["name", "category"],
|
||||
name="unique_flavor_name_per_category",
|
||||
)
|
||||
]
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} ({self.category.name})"
|
||||
|
||||
@property
|
||||
def get_average_rating(self) -> float:
|
||||
result = self.ratings.aggregate(avg=Avg("score"))["avg"]
|
||||
return result or 0.0
|
||||
|
||||
@property
|
||||
def cached_image_url(self) -> str | None:
|
||||
if self.image:
|
||||
return self.image.url
|
||||
return self.image_url
|
||||
@@ -0,0 +1,30 @@
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Rating(models.Model):
|
||||
user = models.ForeignKey("api.User", related_name="ratings", on_delete=models.CASCADE)
|
||||
flavor = models.ForeignKey("api.Flavor", related_name="ratings", on_delete=models.CASCADE)
|
||||
score = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(10)])
|
||||
comment = models.TextField(blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("user", "flavor")
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user.username} - {self.flavor.name}: {self.score}"
|
||||
|
||||
|
||||
class Reply(models.Model):
|
||||
user = models.ForeignKey("api.User", related_name="replies", on_delete=models.CASCADE)
|
||||
rating = models.ForeignKey(Rating, related_name="replies", on_delete=models.CASCADE)
|
||||
text = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Reply by {self.user.username} on {self.rating.id}"
|
||||
@@ -0,0 +1,45 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Notification(models.Model):
|
||||
TYPE_CHOICES = [
|
||||
("reply", "Reply"),
|
||||
("mention", "Mention"),
|
||||
("follow", "Follow"),
|
||||
("ticket_new", "New Ticket"),
|
||||
("ticket_reply", "Ticket Reply"),
|
||||
("profile_comment", "Profile Comment"),
|
||||
]
|
||||
recipient = models.ForeignKey(
|
||||
"api.User", related_name="notifications", on_delete=models.CASCADE
|
||||
)
|
||||
actor = models.ForeignKey("api.User", related_name="actions", on_delete=models.CASCADE)
|
||||
notification_type = models.CharField(max_length=20, choices=TYPE_CHOICES)
|
||||
rating = models.ForeignKey("api.Rating", on_delete=models.CASCADE, null=True, blank=True)
|
||||
reply = models.ForeignKey("api.Reply", on_delete=models.CASCADE, null=True, blank=True)
|
||||
ticket = models.ForeignKey("api.Ticket", on_delete=models.CASCADE, null=True, blank=True)
|
||||
profile_comment = models.ForeignKey(
|
||||
"api.ProfileComment", on_delete=models.CASCADE, null=True, blank=True
|
||||
)
|
||||
is_read = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.notification_type} for {self.recipient.username}"
|
||||
|
||||
|
||||
class ProfileComment(models.Model):
|
||||
profile_owner = models.ForeignKey(
|
||||
"api.User", related_name="profile_comments", on_delete=models.CASCADE
|
||||
)
|
||||
author = models.ForeignKey(
|
||||
"api.User", related_name="authored_profile_comments", on_delete=models.CASCADE
|
||||
)
|
||||
text = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
@@ -0,0 +1,29 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Ticket(models.Model):
|
||||
STATUS_CHOICES = [
|
||||
("open", "Open"),
|
||||
("in_progress", "In Progress"),
|
||||
("resolved", "Resolved"),
|
||||
("closed", "Closed"),
|
||||
]
|
||||
user = models.ForeignKey("api.User", related_name="tickets", on_delete=models.CASCADE)
|
||||
subject = models.CharField(max_length=200)
|
||||
description = models.TextField()
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="open")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-updated_at"]
|
||||
|
||||
|
||||
class TicketMessage(models.Model):
|
||||
ticket = models.ForeignKey(Ticket, related_name="messages", on_delete=models.CASCADE)
|
||||
user = models.ForeignKey("api.User", on_delete=models.CASCADE)
|
||||
text = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["created_at"]
|
||||
@@ -0,0 +1,77 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Banner(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
slug = models.SlugField(unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
is_active = models.BooleanField(
|
||||
default=False, help_text="Designates this as the global default banner."
|
||||
)
|
||||
is_enabled = models.BooleanField(default=True, help_text="Allows users to select this banner.")
|
||||
settings = models.JSONField(default=dict, blank=True)
|
||||
schema = models.JSONField(default=list, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.is_active:
|
||||
Banner.objects.filter(is_active=True).exclude(pk=self.pk).update(is_active=False)
|
||||
elif not Banner.objects.filter(is_active=True).exclude(pk=self.pk).exists():
|
||||
self.is_active = True
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class SystemConfig(models.Model):
|
||||
"""Singleton model for application-wide settings editable via UI."""
|
||||
|
||||
site_name = models.CharField(max_length=100, default="Holy Flavors Archive")
|
||||
maintenance_mode = models.BooleanField(default=False)
|
||||
allow_new_signups = models.BooleanField(default=True)
|
||||
require_email_verification = models.BooleanField(default=True)
|
||||
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "System Configuration"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.pk = 1
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def get_solo(cls) -> "SystemConfig":
|
||||
obj, _ = cls.objects.get_or_create(pk=1)
|
||||
return obj
|
||||
|
||||
|
||||
class Job(models.Model):
|
||||
"""Tracks background jobs, their status and scheduling."""
|
||||
|
||||
JOB_TYPES = [
|
||||
("sync_flavors", "Sync Holy Flavors"),
|
||||
("cleanup_duplicates", "Cleanup Duplicate Flavors"),
|
||||
("backup_db", "Database Backup"),
|
||||
("seed_legacy", "Seed Legacy Data"),
|
||||
]
|
||||
STATUS_CHOICES = [
|
||||
("pending", "Pending"),
|
||||
("running", "Running"),
|
||||
("completed", "Completed"),
|
||||
("failed", "Failed"),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=100, choices=JOB_TYPES)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending")
|
||||
last_run = models.DateTimeField(null=True, blank=True)
|
||||
next_run = models.DateTimeField(null=True, blank=True)
|
||||
interval_hours = models.IntegerField(default=0, help_text="Set to 0 to disable periodic run.")
|
||||
|
||||
last_output = models.TextField(blank=True)
|
||||
error_message = models.TextField(blank=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.get_name_display()} ({self.status})"
|
||||
@@ -0,0 +1,72 @@
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
THEME_CHOICES = [
|
||||
("holy_light", "Holy Light"),
|
||||
("holy_dark", "Holy Dark"),
|
||||
("latte", "Catppuccin Latte"),
|
||||
("frappe", "Catppuccin Frappé"),
|
||||
("macchiato", "Catppuccin Macchiato"),
|
||||
("mocha", "Catppuccin Mocha"),
|
||||
("pink_pastel", "Pink Pastel"),
|
||||
("mint_pastel", "Mint Pastel"),
|
||||
("lavender_pastel", "Lavender Pastel"),
|
||||
("dracula", "Dracula"),
|
||||
("nord", "Nordic Frost"),
|
||||
("gruvbox", "Gruvbox Retro"),
|
||||
("oceanic", "Oceanic Deep"),
|
||||
("t0p_sai", "SAI (T0P)"),
|
||||
("t0p_trench", "Trench (T0P)"),
|
||||
("t0p_blurryface", "Blurryface (T0P)"),
|
||||
("t0p_clancy", "Clancy (T0P)"),
|
||||
]
|
||||
LANGUAGE_CHOICES = [
|
||||
("en", "English"),
|
||||
("de", "Deutsch"),
|
||||
]
|
||||
theme = models.CharField(max_length=20, choices=THEME_CHOICES, default="holy_light")
|
||||
language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default="en")
|
||||
avatar = models.ImageField(upload_to="avatars/", blank=True, null=True)
|
||||
pending_email = models.EmailField(max_length=254, blank=True, null=True)
|
||||
email_confirmation_code = models.CharField(max_length=6, blank=True, null=True)
|
||||
following = models.ManyToManyField(
|
||||
"self", symmetrical=False, related_name="followers", blank=True
|
||||
)
|
||||
selected_banner = models.ForeignKey(
|
||||
"api.Banner",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="users",
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
if self.avatar:
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(self.avatar.path)
|
||||
if img.height > 256 or img.width > 256 or img.format != "JPEG":
|
||||
output_size = (256, 256)
|
||||
img.thumbnail(output_size)
|
||||
if img.mode in ("RGBA", "P"):
|
||||
img = img.convert("RGB")
|
||||
img.save(self.avatar.path, "JPEG", quality=90)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.username
|
||||
|
||||
|
||||
class UserIP(models.Model):
|
||||
user = models.ForeignKey(User, related_name="ips", on_delete=models.CASCADE)
|
||||
ip_address = models.GenericIPAddressField()
|
||||
last_login = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("user", "ip_address")
|
||||
ordering = ["-last_login"]
|
||||
@@ -1,237 +0,0 @@
|
||||
from rest_framework import serializers
|
||||
from .models import User, Flavor, Category, Rating, Reply, Notification, Ticket, TicketMessage, UserIP, Banner, SystemConfig, Job
|
||||
|
||||
class CategorySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Category
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
class SystemConfigSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = SystemConfig
|
||||
fields = ['site_name', 'maintenance_mode', 'allow_new_signups', 'require_email_verification', 'updated_at']
|
||||
|
||||
class JobSerializer(serializers.ModelSerializer):
|
||||
name_display = serializers.CharField(source='get_name_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Job
|
||||
fields = ['id', 'name', 'name_display', 'status', 'last_run', 'next_run', 'interval_hours', 'last_output', 'error_message']
|
||||
read_only_fields = ['status', 'last_run', 'next_run', 'last_output', 'error_message']
|
||||
|
||||
class BannerSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Banner
|
||||
fields = ['id', 'name', 'slug', 'description', 'is_active', 'is_enabled', 'settings', 'schema', 'created_at', 'updated_at']
|
||||
|
||||
class ReplySerializer(serializers.ModelSerializer):
|
||||
user = serializers.StringRelatedField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Reply
|
||||
fields = ['id', 'user', 'rating', 'text', 'created_at']
|
||||
read_only_fields = ['rating']
|
||||
|
||||
class RatingSerializer(serializers.ModelSerializer):
|
||||
user = serializers.StringRelatedField(read_only=True)
|
||||
user_id = serializers.IntegerField(source='user.id', read_only=True)
|
||||
user_avatar = serializers.SerializerMethodField()
|
||||
flavor_name = serializers.CharField(source='flavor.name', read_only=True)
|
||||
flavor_image = serializers.SerializerMethodField()
|
||||
category_name = serializers.CharField(source='flavor.category.name', read_only=True)
|
||||
category_slug = serializers.CharField(source='flavor.category.slug', read_only=True)
|
||||
is_available = serializers.BooleanField(source='flavor.is_available', read_only=True)
|
||||
is_legacy = serializers.BooleanField(source='flavor.is_legacy', read_only=True)
|
||||
replies = ReplySerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Rating
|
||||
fields = ['id', 'user', 'user_id', 'user_avatar', 'flavor', 'flavor_name', 'flavor_image', 'category_name', 'category_slug', 'is_available', 'is_legacy', 'score', 'comment', 'created_at', 'replies']
|
||||
|
||||
def get_user_avatar(self, obj):
|
||||
if obj.user.avatar:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.user.avatar.url)
|
||||
return obj.user.avatar.url
|
||||
return None
|
||||
|
||||
def get_flavor_image(self, obj):
|
||||
if obj.flavor.image:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.flavor.image.url)
|
||||
return obj.flavor.image.url
|
||||
return obj.flavor.image_url
|
||||
|
||||
class FlavorSerializer(serializers.ModelSerializer):
|
||||
category_name = serializers.CharField(source='category.name', read_only=True)
|
||||
category_slug = serializers.CharField(source='category.slug', read_only=True)
|
||||
average_rating = serializers.FloatField(read_only=True)
|
||||
followed_average_rating = serializers.FloatField(read_only=True)
|
||||
user_rating = serializers.SerializerMethodField()
|
||||
ratings = RatingSerializer(many=True, read_only=True)
|
||||
image_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Flavor
|
||||
fields = ['id', 'name', 'category', 'category_name', 'category_slug', 'description', 'average_rating', 'followed_average_rating', 'user_rating', 'ratings', 'image_url', 'image', 'is_available', 'is_legacy', 'shop_url']
|
||||
extra_kwargs = {
|
||||
'image': {'write_only': True}
|
||||
}
|
||||
|
||||
def get_image_url(self, obj):
|
||||
if obj.image:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.image.url)
|
||||
return obj.image.url
|
||||
return obj.image_url
|
||||
|
||||
def get_user_rating(self, obj):
|
||||
request = self.context.get('request')
|
||||
if request and request.user.is_authenticated:
|
||||
try:
|
||||
rating = request.user.ratings.get(flavor=obj)
|
||||
return rating.score
|
||||
except Rating.DoesNotExist:
|
||||
return None
|
||||
return None
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
avatar = serializers.SerializerMethodField()
|
||||
following_count = serializers.IntegerField(source='following.count', read_only=True)
|
||||
followers_count = serializers.IntegerField(source='followers.count', read_only=True)
|
||||
is_following = serializers.SerializerMethodField()
|
||||
unread_notifications_count = serializers.SerializerMethodField()
|
||||
is_superuser = serializers.BooleanField(read_only=True)
|
||||
selected_banner_slug = serializers.CharField(source='selected_banner.slug', read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'email', 'theme', 'language', 'avatar', 'following_count', 'followers_count', 'is_following', 'unread_notifications_count', 'is_superuser', 'selected_banner', 'selected_banner_slug']
|
||||
|
||||
def get_avatar(self, obj):
|
||||
if obj.avatar:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.avatar.url)
|
||||
return obj.avatar.url
|
||||
return None
|
||||
|
||||
def get_is_following(self, obj):
|
||||
request = self.context.get('request')
|
||||
if request and request.user.is_authenticated:
|
||||
return request.user.following.filter(pk=obj.pk).exists()
|
||||
return False
|
||||
|
||||
def get_unread_notifications_count(self, obj):
|
||||
return obj.notifications.filter(is_read=False).count()
|
||||
|
||||
class UserIPSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = UserIP
|
||||
fields = ['id', 'ip_address', 'last_login']
|
||||
|
||||
class TicketMessageSerializer(serializers.ModelSerializer):
|
||||
username = serializers.CharField(source='user.username', read_only=True)
|
||||
is_admin = serializers.BooleanField(source='user.is_superuser', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = TicketMessage
|
||||
fields = ['id', 'username', 'text', 'created_at', 'is_admin']
|
||||
|
||||
class TicketSerializer(serializers.ModelSerializer):
|
||||
username = serializers.CharField(source='user.username', read_only=True)
|
||||
user_email = serializers.EmailField(source='user.email', read_only=True)
|
||||
user_avatar = serializers.SerializerMethodField()
|
||||
messages = TicketMessageSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Ticket
|
||||
fields = ['id', 'user', 'username', 'user_email', 'user_avatar', 'subject', 'description', 'status', 'created_at', 'updated_at', 'messages']
|
||||
read_only_fields = ['status', 'user']
|
||||
|
||||
def get_user_avatar(self, obj):
|
||||
if obj.user.avatar:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.user.avatar.url)
|
||||
return obj.user.avatar.url
|
||||
return None
|
||||
|
||||
class ProfileCommentSerializer(serializers.ModelSerializer):
|
||||
author_username = serializers.CharField(source='author.username', read_only=True)
|
||||
author_avatar = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
from .models import ProfileComment
|
||||
model = ProfileComment
|
||||
fields = ['id', 'author_username', 'author_avatar', 'text', 'created_at']
|
||||
|
||||
def get_author_avatar(self, obj):
|
||||
if obj.author.avatar:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.author.avatar.url)
|
||||
return obj.author.avatar.url
|
||||
return None
|
||||
|
||||
class AdminUserListSerializer(serializers.ModelSerializer):
|
||||
ips = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'email', 'is_active', 'is_superuser', 'date_joined', 'last_login', 'ips']
|
||||
|
||||
def get_ips(self, obj):
|
||||
return [ip.ip_address for ip in obj.ips.all()[:3]]
|
||||
|
||||
class AdminUserDetailSerializer(serializers.ModelSerializer):
|
||||
ips = serializers.SerializerMethodField()
|
||||
ratings = RatingSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'email', 'is_active', 'is_superuser', 'date_joined', 'last_login', 'ips', 'ratings']
|
||||
|
||||
def get_ips(self, obj):
|
||||
return [ip.ip_address for ip in obj.ips.all()]
|
||||
|
||||
class NotificationSerializer(serializers.ModelSerializer):
|
||||
actor_username = serializers.CharField(source='actor.username', read_only=True)
|
||||
actor_avatar = serializers.SerializerMethodField()
|
||||
flavor_name = serializers.SerializerMethodField()
|
||||
flavor_id = serializers.SerializerMethodField()
|
||||
profile_owner_username = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Notification
|
||||
fields = ['id', 'actor_username', 'actor_avatar', 'notification_type', 'rating', 'reply', 'ticket', 'profile_comment', 'profile_owner_username', 'is_read', 'created_at', 'flavor_name', 'flavor_id']
|
||||
|
||||
def get_actor_avatar(self, obj):
|
||||
if obj.actor.avatar:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.actor.avatar.url)
|
||||
return obj.actor.avatar.url
|
||||
return None
|
||||
|
||||
def get_flavor_name(self, obj):
|
||||
if obj.rating:
|
||||
return obj.rating.flavor.name
|
||||
if obj.reply:
|
||||
return obj.reply.rating.flavor.name
|
||||
return None
|
||||
|
||||
def get_flavor_id(self, obj):
|
||||
if obj.rating:
|
||||
return obj.rating.flavor.id
|
||||
if obj.reply:
|
||||
return obj.reply.rating.flavor.id
|
||||
return None
|
||||
|
||||
def get_profile_owner_username(self, obj):
|
||||
if obj.profile_comment:
|
||||
return obj.profile_comment.profile_owner.username
|
||||
return None
|
||||
@@ -0,0 +1,33 @@
|
||||
from api.serializers.flavor import CategorySerializer, FlavorSerializer
|
||||
from api.serializers.rating import RatingSerializer, ReplySerializer
|
||||
from api.serializers.social import NotificationSerializer, ProfileCommentSerializer
|
||||
from api.serializers.support import TicketMessageSerializer, TicketSerializer
|
||||
from api.serializers.system import (
|
||||
BannerSerializer,
|
||||
JobSerializer,
|
||||
SystemConfigSerializer,
|
||||
)
|
||||
from api.serializers.user import (
|
||||
AdminUserDetailSerializer,
|
||||
AdminUserListSerializer,
|
||||
UserIPSerializer,
|
||||
UserSerializer,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AdminUserDetailSerializer",
|
||||
"AdminUserListSerializer",
|
||||
"BannerSerializer",
|
||||
"CategorySerializer",
|
||||
"FlavorSerializer",
|
||||
"JobSerializer",
|
||||
"NotificationSerializer",
|
||||
"ProfileCommentSerializer",
|
||||
"RatingSerializer",
|
||||
"ReplySerializer",
|
||||
"SystemConfigSerializer",
|
||||
"TicketMessageSerializer",
|
||||
"TicketSerializer",
|
||||
"UserIPSerializer",
|
||||
"UserSerializer",
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
from typing import Any
|
||||
|
||||
|
||||
def absolute_image_url(serializer: Any, image_field: Any) -> str | None:
|
||||
if not image_field:
|
||||
return None
|
||||
request = serializer.context.get("request")
|
||||
if request:
|
||||
return request.build_absolute_uri(image_field.url)
|
||||
return image_field.url
|
||||
@@ -0,0 +1,56 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from api.models import Category, Flavor, Rating
|
||||
from api.serializers._helpers import absolute_image_url
|
||||
from api.serializers.rating import RatingSerializer
|
||||
|
||||
|
||||
class CategorySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Category
|
||||
fields = ["id", "name", "slug"]
|
||||
|
||||
|
||||
class FlavorSerializer(serializers.ModelSerializer):
|
||||
category_name = serializers.CharField(source="category.name", read_only=True)
|
||||
category_slug = serializers.CharField(source="category.slug", read_only=True)
|
||||
average_rating = serializers.FloatField(read_only=True)
|
||||
followed_average_rating = serializers.FloatField(read_only=True)
|
||||
user_rating = serializers.SerializerMethodField()
|
||||
ratings = RatingSerializer(many=True, read_only=True)
|
||||
image_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Flavor
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"category",
|
||||
"category_name",
|
||||
"category_slug",
|
||||
"description",
|
||||
"average_rating",
|
||||
"followed_average_rating",
|
||||
"user_rating",
|
||||
"ratings",
|
||||
"image_url",
|
||||
"image",
|
||||
"is_available",
|
||||
"is_legacy",
|
||||
"shop_url",
|
||||
]
|
||||
extra_kwargs = {"image": {"write_only": True}}
|
||||
|
||||
def get_image_url(self, obj: Flavor) -> str | None:
|
||||
if obj.image:
|
||||
return absolute_image_url(self, obj.image)
|
||||
return obj.image_url
|
||||
|
||||
def get_user_rating(self, obj: Flavor) -> int | None:
|
||||
request = self.context.get("request")
|
||||
if request and request.user.is_authenticated:
|
||||
try:
|
||||
return request.user.ratings.get(flavor=obj).score
|
||||
except Rating.DoesNotExist:
|
||||
return None
|
||||
return None
|
||||
@@ -0,0 +1,54 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from api.models import Rating, Reply
|
||||
from api.serializers._helpers import absolute_image_url
|
||||
|
||||
|
||||
class ReplySerializer(serializers.ModelSerializer):
|
||||
user = serializers.StringRelatedField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Reply
|
||||
fields = ["id", "user", "rating", "text", "created_at"]
|
||||
read_only_fields = ["rating"]
|
||||
|
||||
|
||||
class RatingSerializer(serializers.ModelSerializer):
|
||||
user = serializers.StringRelatedField(read_only=True)
|
||||
user_id = serializers.IntegerField(source="user.id", read_only=True)
|
||||
user_avatar = serializers.SerializerMethodField()
|
||||
flavor_name = serializers.CharField(source="flavor.name", read_only=True)
|
||||
flavor_image = serializers.SerializerMethodField()
|
||||
category_name = serializers.CharField(source="flavor.category.name", read_only=True)
|
||||
category_slug = serializers.CharField(source="flavor.category.slug", read_only=True)
|
||||
is_available = serializers.BooleanField(source="flavor.is_available", read_only=True)
|
||||
is_legacy = serializers.BooleanField(source="flavor.is_legacy", read_only=True)
|
||||
replies = ReplySerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Rating
|
||||
fields = [
|
||||
"id",
|
||||
"user",
|
||||
"user_id",
|
||||
"user_avatar",
|
||||
"flavor",
|
||||
"flavor_name",
|
||||
"flavor_image",
|
||||
"category_name",
|
||||
"category_slug",
|
||||
"is_available",
|
||||
"is_legacy",
|
||||
"score",
|
||||
"comment",
|
||||
"created_at",
|
||||
"replies",
|
||||
]
|
||||
|
||||
def get_user_avatar(self, obj: Rating) -> str | None:
|
||||
return absolute_image_url(self, obj.user.avatar)
|
||||
|
||||
def get_flavor_image(self, obj: Rating) -> str | None:
|
||||
if obj.flavor.image:
|
||||
return absolute_image_url(self, obj.flavor.image)
|
||||
return obj.flavor.image_url
|
||||
@@ -0,0 +1,64 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from api.models import Notification, ProfileComment
|
||||
from api.serializers._helpers import absolute_image_url
|
||||
|
||||
|
||||
class ProfileCommentSerializer(serializers.ModelSerializer):
|
||||
author_username = serializers.CharField(source="author.username", read_only=True)
|
||||
author_avatar = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ProfileComment
|
||||
fields = ["id", "author_username", "author_avatar", "text", "created_at"]
|
||||
|
||||
def get_author_avatar(self, obj: ProfileComment) -> str | None:
|
||||
return absolute_image_url(self, obj.author.avatar)
|
||||
|
||||
|
||||
class NotificationSerializer(serializers.ModelSerializer):
|
||||
actor_username = serializers.CharField(source="actor.username", read_only=True)
|
||||
actor_avatar = serializers.SerializerMethodField()
|
||||
flavor_name = serializers.SerializerMethodField()
|
||||
flavor_id = serializers.SerializerMethodField()
|
||||
profile_owner_username = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Notification
|
||||
fields = [
|
||||
"id",
|
||||
"actor_username",
|
||||
"actor_avatar",
|
||||
"notification_type",
|
||||
"rating",
|
||||
"reply",
|
||||
"ticket",
|
||||
"profile_comment",
|
||||
"profile_owner_username",
|
||||
"is_read",
|
||||
"created_at",
|
||||
"flavor_name",
|
||||
"flavor_id",
|
||||
]
|
||||
|
||||
def get_actor_avatar(self, obj: Notification) -> str | None:
|
||||
return absolute_image_url(self, obj.actor.avatar)
|
||||
|
||||
def get_flavor_name(self, obj: Notification) -> str | None:
|
||||
if obj.rating:
|
||||
return obj.rating.flavor.name
|
||||
if obj.reply:
|
||||
return obj.reply.rating.flavor.name
|
||||
return None
|
||||
|
||||
def get_flavor_id(self, obj: Notification) -> int | None:
|
||||
if obj.rating:
|
||||
return obj.rating.flavor.id
|
||||
if obj.reply:
|
||||
return obj.reply.rating.flavor.id
|
||||
return None
|
||||
|
||||
def get_profile_owner_username(self, obj: Notification) -> str | None:
|
||||
if obj.profile_comment:
|
||||
return obj.profile_comment.profile_owner.username
|
||||
return None
|
||||
@@ -0,0 +1,40 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from api.models import Ticket, TicketMessage
|
||||
from api.serializers._helpers import absolute_image_url
|
||||
|
||||
|
||||
class TicketMessageSerializer(serializers.ModelSerializer):
|
||||
username = serializers.CharField(source="user.username", read_only=True)
|
||||
is_admin = serializers.BooleanField(source="user.is_superuser", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = TicketMessage
|
||||
fields = ["id", "username", "text", "created_at", "is_admin"]
|
||||
|
||||
|
||||
class TicketSerializer(serializers.ModelSerializer):
|
||||
username = serializers.CharField(source="user.username", read_only=True)
|
||||
user_email = serializers.EmailField(source="user.email", read_only=True)
|
||||
user_avatar = serializers.SerializerMethodField()
|
||||
messages = TicketMessageSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Ticket
|
||||
fields = [
|
||||
"id",
|
||||
"user",
|
||||
"username",
|
||||
"user_email",
|
||||
"user_avatar",
|
||||
"subject",
|
||||
"description",
|
||||
"status",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"messages",
|
||||
]
|
||||
read_only_fields = ["status", "user"]
|
||||
|
||||
def get_user_avatar(self, obj: Ticket) -> str | None:
|
||||
return absolute_image_url(self, obj.user.avatar)
|
||||
@@ -0,0 +1,57 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from api.models import Banner, Job, SystemConfig
|
||||
|
||||
|
||||
class SystemConfigSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = SystemConfig
|
||||
fields = [
|
||||
"site_name",
|
||||
"maintenance_mode",
|
||||
"allow_new_signups",
|
||||
"require_email_verification",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class JobSerializer(serializers.ModelSerializer):
|
||||
name_display = serializers.CharField(source="get_name_display", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Job
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"name_display",
|
||||
"status",
|
||||
"last_run",
|
||||
"next_run",
|
||||
"interval_hours",
|
||||
"last_output",
|
||||
"error_message",
|
||||
]
|
||||
read_only_fields = [
|
||||
"status",
|
||||
"last_run",
|
||||
"next_run",
|
||||
"last_output",
|
||||
"error_message",
|
||||
]
|
||||
|
||||
|
||||
class BannerSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Banner
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"description",
|
||||
"is_active",
|
||||
"is_enabled",
|
||||
"settings",
|
||||
"schema",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
@@ -0,0 +1,95 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from api.models import User, UserIP
|
||||
from api.serializers._helpers import absolute_image_url
|
||||
from api.serializers.rating import RatingSerializer
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
avatar = serializers.SerializerMethodField()
|
||||
following_count = serializers.IntegerField(source="following.count", read_only=True)
|
||||
followers_count = serializers.IntegerField(source="followers.count", read_only=True)
|
||||
is_following = serializers.SerializerMethodField()
|
||||
unread_notifications_count = serializers.SerializerMethodField()
|
||||
is_superuser = serializers.BooleanField(read_only=True)
|
||||
selected_banner_slug = serializers.CharField(
|
||||
source="selected_banner.slug", read_only=True, allow_null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"id",
|
||||
"username",
|
||||
"email",
|
||||
"theme",
|
||||
"language",
|
||||
"avatar",
|
||||
"following_count",
|
||||
"followers_count",
|
||||
"is_following",
|
||||
"unread_notifications_count",
|
||||
"is_superuser",
|
||||
"selected_banner",
|
||||
"selected_banner_slug",
|
||||
]
|
||||
|
||||
def get_avatar(self, obj: User) -> str | None:
|
||||
return absolute_image_url(self, obj.avatar)
|
||||
|
||||
def get_is_following(self, obj: User) -> bool:
|
||||
request = self.context.get("request")
|
||||
if request and request.user.is_authenticated:
|
||||
return request.user.following.filter(pk=obj.pk).exists()
|
||||
return False
|
||||
|
||||
def get_unread_notifications_count(self, obj: User) -> int:
|
||||
return obj.notifications.filter(is_read=False).count()
|
||||
|
||||
|
||||
class UserIPSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = UserIP
|
||||
fields = ["id", "ip_address", "last_login"]
|
||||
|
||||
|
||||
class AdminUserListSerializer(serializers.ModelSerializer):
|
||||
ips = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"id",
|
||||
"username",
|
||||
"email",
|
||||
"is_active",
|
||||
"is_superuser",
|
||||
"date_joined",
|
||||
"last_login",
|
||||
"ips",
|
||||
]
|
||||
|
||||
def get_ips(self, obj: User) -> list[str]:
|
||||
return [ip.ip_address for ip in obj.ips.all()[:3]]
|
||||
|
||||
|
||||
class AdminUserDetailSerializer(serializers.ModelSerializer):
|
||||
ips = serializers.SerializerMethodField()
|
||||
ratings = RatingSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"id",
|
||||
"username",
|
||||
"email",
|
||||
"is_active",
|
||||
"is_superuser",
|
||||
"date_joined",
|
||||
"last_login",
|
||||
"ips",
|
||||
"ratings",
|
||||
]
|
||||
|
||||
def get_ips(self, obj: User) -> list[str]:
|
||||
return [ip.ip_address for ip in obj.ips.all()]
|
||||
Whitespace-only changes.
@@ -0,0 +1,14 @@
|
||||
from rest_framework.request import Request
|
||||
|
||||
from api.models import User, UserIP
|
||||
|
||||
|
||||
def log_user_ip(user: User, request: Request) -> None:
|
||||
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(",")[0].strip()
|
||||
else:
|
||||
ip = request.META.get("REMOTE_ADDR")
|
||||
|
||||
if ip and user.is_authenticated:
|
||||
UserIP.objects.update_or_create(user=user, ip_address=ip)
|
||||
@@ -0,0 +1,29 @@
|
||||
import re
|
||||
|
||||
from api.models import Notification, Rating, Reply, User
|
||||
|
||||
_MENTION_RE = re.compile(r"@(\w+)")
|
||||
|
||||
|
||||
def parse_mentions(
|
||||
text: str,
|
||||
actor: User,
|
||||
rating: Rating | None = None,
|
||||
reply: Reply | None = None,
|
||||
) -> None:
|
||||
if not text:
|
||||
return
|
||||
for username in set(_MENTION_RE.findall(text)):
|
||||
try:
|
||||
recipient = User.objects.get(username__iexact=username)
|
||||
except User.DoesNotExist:
|
||||
continue
|
||||
if recipient == actor:
|
||||
continue
|
||||
Notification.objects.get_or_create(
|
||||
recipient=recipient,
|
||||
actor=actor,
|
||||
notification_type="mention",
|
||||
rating=rating,
|
||||
reply=reply,
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
from django.db.models import Q, QuerySet
|
||||
|
||||
from api.models import Flavor
|
||||
|
||||
CATEGORY_KEYWORDS: dict[str, str] = {
|
||||
"iced tea": "iced-tea",
|
||||
"eistee": "iced-tea",
|
||||
"energy": "energy",
|
||||
"hydration": "hydration",
|
||||
"milkshake": "milkshake",
|
||||
}
|
||||
|
||||
|
||||
def extract_category_slug(query: str) -> tuple[str | None, str]:
|
||||
lowered = query.lower().strip()
|
||||
for keyword, slug in CATEGORY_KEYWORDS.items():
|
||||
if keyword in lowered:
|
||||
return slug, lowered.replace(keyword, "").strip()
|
||||
return None, lowered
|
||||
|
||||
|
||||
def filter_flavors_by_query(queryset: QuerySet[Flavor], query: str) -> QuerySet[Flavor]:
|
||||
slug, remaining = extract_category_slug(query)
|
||||
if slug:
|
||||
queryset = queryset.filter(category__slug=slug)
|
||||
if remaining:
|
||||
word_query = Q()
|
||||
for word in remaining.split():
|
||||
if len(word) > 2:
|
||||
word_query |= Q(name__icontains=word) | Q(description__icontains=word)
|
||||
if word_query:
|
||||
queryset = queryset.filter(word_query)
|
||||
return queryset
|
||||
|
||||
|
||||
def score_relevance(name: str, query: str) -> int:
|
||||
name_lower = name.lower()
|
||||
if name_lower == query:
|
||||
return 3
|
||||
if name_lower.startswith(query):
|
||||
return 2
|
||||
if query in name_lower:
|
||||
return 1
|
||||
return 0
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Celery tasks for Holy Flavors.
|
||||
|
||||
Each @shared_task wraps an existing management command so that the same
|
||||
code path remains usable from the CLI for manual ops. The legacy ``Job``
|
||||
rows are kept as an execution log: tasks update the matching row with
|
||||
status/output/timestamps so the admin UI keeps working.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from io import StringIO
|
||||
from typing import Any
|
||||
|
||||
from celery import shared_task
|
||||
from celery.utils.log import get_task_logger
|
||||
from django.core.mail import send_mail
|
||||
from django.core.management import call_command
|
||||
from django.utils import timezone
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def _run_command_job(job_name: str, command: str, *args: str) -> str:
|
||||
"""Run a management command and mirror status/output into the Job row."""
|
||||
from api.models import Job
|
||||
|
||||
job, _ = Job.objects.get_or_create(name=job_name)
|
||||
job.status = "running"
|
||||
job.last_run = timezone.now()
|
||||
job.last_output = ""
|
||||
job.error_message = ""
|
||||
job.save(update_fields=["status", "last_run", "last_output", "error_message"])
|
||||
|
||||
out = StringIO()
|
||||
try:
|
||||
call_command(command, *args, stdout=out)
|
||||
job.status = "completed"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
job.status = "failed"
|
||||
job.error_message = str(exc)
|
||||
logger.exception("Job %s failed", job_name)
|
||||
raise
|
||||
finally:
|
||||
job.last_output = out.getvalue()
|
||||
if job.interval_hours > 0:
|
||||
job.next_run = timezone.now() + timedelta(hours=job.interval_hours)
|
||||
else:
|
||||
job.next_run = None
|
||||
job.save()
|
||||
|
||||
return job.last_output
|
||||
|
||||
|
||||
@shared_task(name="api.sync_flavors")
|
||||
def sync_flavors_task() -> str:
|
||||
return _run_command_job("sync_flavors", "sync_flavors")
|
||||
|
||||
|
||||
@shared_task(name="api.cleanup_duplicates")
|
||||
def cleanup_duplicates_task() -> str:
|
||||
return _run_command_job("cleanup_duplicates", "cleanup_duplicates")
|
||||
|
||||
|
||||
@shared_task(name="api.backup_db")
|
||||
def backup_db_task() -> str:
|
||||
return _run_command_job("backup_db", "backup_db", "--full")
|
||||
|
||||
|
||||
@shared_task(name="api.seed_legacy")
|
||||
def seed_legacy_task() -> str:
|
||||
return _run_command_job("seed_legacy", "seed_legacy_flavors")
|
||||
|
||||
|
||||
@shared_task(
|
||||
name="api.send_email",
|
||||
autoretry_for=(Exception,),
|
||||
retry_backoff=True,
|
||||
retry_kwargs={"max_retries": 3},
|
||||
)
|
||||
def send_email_task(
|
||||
subject: str,
|
||||
message: str,
|
||||
recipient_list: list[str],
|
||||
from_email: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> int:
|
||||
"""Async wrapper around django.core.mail.send_mail."""
|
||||
return send_mail(
|
||||
subject=subject,
|
||||
message=message,
|
||||
from_email=from_email,
|
||||
recipient_list=recipient_list,
|
||||
fail_silently=False,
|
||||
**kwargs,
|
||||
)
|
||||
@@ -1,45 +0,0 @@
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
from .models import Category, Flavor, User, Rating
|
||||
|
||||
class FlavorTopTests(APITestCase):
|
||||
def setUp(self):
|
||||
self.category_energy = Category.objects.create(name='Energy', slug='energy')
|
||||
self.category_iced_tea = Category.objects.create(name='Iced Tea', slug='iced-tea')
|
||||
|
||||
self.user = User.objects.create_user(username='testuser', password='password')
|
||||
|
||||
# Create flavors
|
||||
self.flavor1 = Flavor.objects.create(name='Energy 1', category=self.category_energy)
|
||||
self.flavor2 = Flavor.objects.create(name='Iced Tea 1', category=self.category_iced_tea)
|
||||
|
||||
# Add ratings to make them appear in top (since we filter by ratings__isnull=False)
|
||||
Rating.objects.create(user=self.user, flavor=self.flavor1, score=5)
|
||||
Rating.objects.create(user=self.user, flavor=self.flavor2, score=4)
|
||||
|
||||
def test_top_flavors_all(self):
|
||||
url = reverse('flavor-top')
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
def test_top_flavors_filtered_energy(self):
|
||||
url = reverse('flavor-top')
|
||||
response = self.client.get(url, {'category': 'energy'})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]['name'], 'Energy 1')
|
||||
|
||||
def test_top_flavors_filtered_iced_tea(self):
|
||||
url = reverse('flavor-top')
|
||||
response = self.client.get(url, {'category': 'iced-tea'})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]['name'], 'Iced Tea 1')
|
||||
|
||||
def test_top_flavors_filtered_non_existent(self):
|
||||
url = reverse('flavor-top')
|
||||
response = self.client.get(url, {'category': 'non-existent'})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 0)
|
||||
Whitespace-only changes.
@@ -0,0 +1,55 @@
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from api.models import User
|
||||
|
||||
|
||||
class JWTAuthTests(APITestCase):
|
||||
def setUp(self) -> None:
|
||||
self.username = "jwtuser"
|
||||
self.password = "correct-horse-battery-staple"
|
||||
self.user = User.objects.create_user(username=self.username, password=self.password)
|
||||
|
||||
def test_obtain_pair_returns_access_and_refresh(self) -> None:
|
||||
url = reverse("token_obtain_pair")
|
||||
response = self.client.post(
|
||||
url,
|
||||
{"username": self.username, "password": self.password},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn("access", response.data)
|
||||
self.assertIn("refresh", response.data)
|
||||
|
||||
def test_access_token_authenticates_protected_endpoint(self) -> None:
|
||||
pair = self.client.post(
|
||||
reverse("token_obtain_pair"),
|
||||
{"username": self.username, "password": self.password},
|
||||
format="json",
|
||||
).data
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {pair['access']}")
|
||||
response = self.client.get("/api/notifications/", follow=True)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_refresh_issues_new_access_token(self) -> None:
|
||||
pair = self.client.post(
|
||||
reverse("token_obtain_pair"),
|
||||
{"username": self.username, "password": self.password},
|
||||
format="json",
|
||||
).data
|
||||
response = self.client.post(
|
||||
reverse("token_refresh"),
|
||||
{"refresh": pair["refresh"]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn("access", response.data)
|
||||
|
||||
def test_invalid_refresh_is_rejected(self) -> None:
|
||||
response = self.client.post(
|
||||
reverse("token_refresh"),
|
||||
{"refresh": "not-a-real-token"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
@@ -0,0 +1,45 @@
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from api.models import Category, Flavor, Rating, User
|
||||
|
||||
|
||||
class FlavorTopTests(APITestCase):
|
||||
def setUp(self) -> None:
|
||||
self.category_energy = Category.objects.create(name="Energy", slug="energy")
|
||||
self.category_iced_tea = Category.objects.create(name="Iced Tea", slug="iced-tea")
|
||||
|
||||
self.user = User.objects.create_user(username="testuser", password="password")
|
||||
|
||||
self.flavor1 = Flavor.objects.create(name="Energy 1", category=self.category_energy)
|
||||
self.flavor2 = Flavor.objects.create(name="Iced Tea 1", category=self.category_iced_tea)
|
||||
|
||||
Rating.objects.create(user=self.user, flavor=self.flavor1, score=5)
|
||||
Rating.objects.create(user=self.user, flavor=self.flavor2, score=4)
|
||||
|
||||
def test_top_flavors_all(self) -> None:
|
||||
url = reverse("flavor-top")
|
||||
response = self.client.get(url, follow=True)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
def test_top_flavors_filtered_energy(self) -> None:
|
||||
url = reverse("flavor-top")
|
||||
response = self.client.get(url, {"category": "energy"}, follow=True)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]["name"], "Energy 1")
|
||||
|
||||
def test_top_flavors_filtered_iced_tea(self) -> None:
|
||||
url = reverse("flavor-top")
|
||||
response = self.client.get(url, {"category": "iced-tea"}, follow=True)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]["name"], "Iced Tea 1")
|
||||
|
||||
def test_top_flavors_filtered_non_existent(self) -> None:
|
||||
url = reverse("flavor-top")
|
||||
response = self.client.get(url, {"category": "non-existent"}, follow=True)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 0)
|
||||
@@ -0,0 +1,19 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from api.models import Category, Flavor, Rating, User
|
||||
|
||||
|
||||
class FlavorAverageRatingTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.category = Category.objects.create(name="Energy", slug="energy")
|
||||
self.flavor = Flavor.objects.create(name="Test", category=self.category)
|
||||
self.u1 = User.objects.create_user(username="u1", password="x")
|
||||
self.u2 = User.objects.create_user(username="u2", password="x")
|
||||
|
||||
def test_average_zero_when_no_ratings(self) -> None:
|
||||
self.assertEqual(self.flavor.get_average_rating, 0.0)
|
||||
|
||||
def test_average_uses_aggregate(self) -> None:
|
||||
Rating.objects.create(user=self.u1, flavor=self.flavor, score=4)
|
||||
Rating.objects.create(user=self.u2, flavor=self.flavor, score=8)
|
||||
self.assertEqual(self.flavor.get_average_rating, 6.0)
|
||||
+12
-11
@@ -1,18 +1,19 @@
|
||||
from django.urls import path, include
|
||||
from django.urls import include, path
|
||||
from rest_framework import routers
|
||||
|
||||
from . import views
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
router.register('users', views.UserViewSet)
|
||||
router.register('flavors', views.FlavorViewSet)
|
||||
router.register('categories', views.CategoryViewSet)
|
||||
router.register('ratings', views.RatingViewSet)
|
||||
router.register('replies', views.ReplyViewSet)
|
||||
router.register('notifications', views.NotificationViewSet, basename='notification')
|
||||
router.register('tickets', views.TicketViewSet, basename='ticket')
|
||||
router.register('admin-custom', views.AdminViewSet, basename='admin-custom')
|
||||
router.register('banners', views.BannerViewSet, basename='banner')
|
||||
router.register("users", views.UserViewSet)
|
||||
router.register("flavors", views.FlavorViewSet)
|
||||
router.register("categories", views.CategoryViewSet)
|
||||
router.register("ratings", views.RatingViewSet)
|
||||
router.register("replies", views.ReplyViewSet)
|
||||
router.register("notifications", views.NotificationViewSet, basename="notification")
|
||||
router.register("tickets", views.TicketViewSet, basename="ticket")
|
||||
router.register("admin-custom", views.AdminViewSet, basename="admin-custom")
|
||||
router.register("banners", views.BannerViewSet, basename="banner")
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
@@ -1,979 +0,0 @@
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from rest_framework import viewsets, permissions, status, filters, serializers
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import action
|
||||
from django.db.models import Avg, Q
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django_ratelimit.decorators import ratelimit
|
||||
from django.utils.decorators import method_decorator
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from .models import User, Flavor, Category, Rating, Reply, Notification, Ticket, TicketMessage, UserIP, ProfileComment, Banner, SystemConfig, Job
|
||||
from .serializers import (
|
||||
UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer,
|
||||
ReplySerializer, NotificationSerializer, TicketSerializer,
|
||||
TicketMessageSerializer, AdminUserListSerializer, AdminUserDetailSerializer,
|
||||
ProfileCommentSerializer, BannerSerializer, SystemConfigSerializer, JobSerializer
|
||||
)
|
||||
|
||||
def log_user_ip(user, request):
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0]
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
|
||||
if ip and user.is_authenticated:
|
||||
UserIP.objects.update_or_create(user=user, ip_address=ip)
|
||||
|
||||
def parse_mentions(text, actor, rating=None, reply=None):
|
||||
import re
|
||||
mentions = re.findall(r'@(\w+)', text)
|
||||
for username in set(mentions):
|
||||
try:
|
||||
recipient = User.objects.get(username__iexact=username)
|
||||
if recipient != actor:
|
||||
Notification.objects.get_or_create(
|
||||
recipient=recipient,
|
||||
actor=actor,
|
||||
notification_type='mention',
|
||||
rating=rating,
|
||||
reply=reply
|
||||
)
|
||||
except User.DoesNotExist:
|
||||
continue
|
||||
|
||||
class FeedPagination(PageNumberPagination):
|
||||
page_size = 10
|
||||
|
||||
class FlavorViewSet(viewsets.ModelViewSet):
|
||||
queryset = Flavor.objects.annotate(average_rating=Avg('ratings__score')).order_by('-average_rating')
|
||||
serializer_class = FlavorSerializer
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
|
||||
filterset_fields = ['category', 'category__slug']
|
||||
search_fields = ['name', 'description']
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
|
||||
def get_queryset(self):
|
||||
qs = Flavor.objects.select_related('category').annotate(average_rating=Avg('ratings__score')).order_by('-average_rating').distinct()
|
||||
|
||||
# Handle category keywords in search query for main results too
|
||||
search_query = self.request.query_params.get('search', '').lower().strip()
|
||||
if search_query:
|
||||
cat_keywords = {
|
||||
'iced tea': 'iced-tea',
|
||||
'eistee': 'iced-tea',
|
||||
'energy': 'energy',
|
||||
'hydration': 'hydration',
|
||||
'milkshake': 'milkshake'
|
||||
}
|
||||
for keyword, slug in cat_keywords.items():
|
||||
if keyword in search_query:
|
||||
qs = qs.filter(category__slug=slug)
|
||||
break # Only one category at a time
|
||||
|
||||
return qs
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
|
||||
def search(self, request):
|
||||
query = request.query_params.get('q', '').lower().strip()
|
||||
if not query:
|
||||
return Response([])
|
||||
|
||||
# Start with all flavors
|
||||
flavors = Flavor.objects.select_related('category').distinct()
|
||||
|
||||
# Intelligent category filtering based on keywords
|
||||
cat_keywords = {
|
||||
'iced tea': 'iced-tea',
|
||||
'eistee': 'iced-tea',
|
||||
'energy': 'energy',
|
||||
'hydration': 'hydration',
|
||||
'milkshake': 'milkshake'
|
||||
}
|
||||
|
||||
active_cat_slug = None
|
||||
remaining_query = query
|
||||
for keyword, slug in cat_keywords.items():
|
||||
if keyword in query:
|
||||
active_cat_slug = slug
|
||||
remaining_query = remaining_query.replace(keyword, '').strip()
|
||||
break
|
||||
|
||||
if active_cat_slug:
|
||||
flavors = flavors.filter(category__slug=active_cat_slug)
|
||||
|
||||
# Filter by remaining query words in name or description
|
||||
if remaining_query:
|
||||
words = remaining_query.split()
|
||||
# Construct a combined OR query for all words to be more permissive (fuzzy-ish)
|
||||
word_query = Q()
|
||||
for word in words:
|
||||
if len(word) > 2: # Only filter by words longer than 2 chars to avoid noise
|
||||
word_query |= Q(name__icontains=word) | Q(description__icontains=word)
|
||||
|
||||
if word_query:
|
||||
flavors = flavors.filter(word_query)
|
||||
|
||||
# Limit results and deduplicate in Python to be safe against DB inconsistencies
|
||||
results_list = []
|
||||
seen_keys = set()
|
||||
|
||||
# Priority sorting in Python
|
||||
search_lower = remaining_query.lower()
|
||||
|
||||
# Order by name, but we'll also try to prioritize exact matches in Python
|
||||
for f in flavors.order_by('name'):
|
||||
# Create a unique key for this flavor (name + category)
|
||||
key = f"{f.name.strip().lower()}|{f.category.slug}"
|
||||
if key in seen_keys:
|
||||
continue
|
||||
|
||||
seen_keys.add(key)
|
||||
|
||||
name_lower = f.name.lower()
|
||||
relevance = 0
|
||||
if name_lower == search_lower:
|
||||
relevance = 3
|
||||
elif name_lower.startswith(search_lower):
|
||||
relevance = 2
|
||||
elif search_lower in name_lower:
|
||||
relevance = 1
|
||||
|
||||
results_list.append({
|
||||
'id': f.id,
|
||||
'name': f.name,
|
||||
'type': 'flavor',
|
||||
'subtitle': f.category.name,
|
||||
'image_url': request.build_absolute_uri(f.image.url) if f.image else f.image_url,
|
||||
'slug': None,
|
||||
'relevance': relevance
|
||||
})
|
||||
|
||||
# Sort by relevance desc, then name
|
||||
results_list.sort(key=lambda x: (-x['relevance'], x['name']))
|
||||
|
||||
# Remove relevance helper and limit to 15
|
||||
final_results = []
|
||||
for r in results_list:
|
||||
del r['relevance']
|
||||
final_results.append(r)
|
||||
if len(final_results) >= 15:
|
||||
break
|
||||
|
||||
return Response(final_results)
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
|
||||
def top(self, request):
|
||||
queryset = self.get_queryset().filter(ratings__isnull=False)
|
||||
category_slug = request.query_params.get('category')
|
||||
if category_slug:
|
||||
queryset = queryset.filter(category__slug=category_slug)
|
||||
top_flavors = queryset.distinct()[:10]
|
||||
serializer = self.get_serializer(top_flavors, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
|
||||
def newest(self, request):
|
||||
# Get the 10 newest flavors
|
||||
newest_flavors = Flavor.objects.select_related('category').order_by('-created_at')[:10]
|
||||
serializer = self.get_serializer(newest_flavors, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
|
||||
def followed_top(self, request):
|
||||
followed_users = request.user.following.all()
|
||||
top_flavors = Flavor.objects.filter(ratings__user__in=followed_users) \
|
||||
.annotate(average_rating=Avg('ratings__score')) \
|
||||
.order_by('-average_rating') \
|
||||
.distinct()[:10]
|
||||
serializer = self.get_serializer(top_flavors, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = Category.objects.all()
|
||||
serializer_class = CategorySerializer
|
||||
|
||||
class RatingViewSet(viewsets.ModelViewSet):
|
||||
queryset = Rating.objects.all()
|
||||
serializer_class = RatingSerializer
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
|
||||
@method_decorator(ratelimit(key='user', rate='10/m', method='POST', block=True))
|
||||
def create(self, request, *args, **kwargs):
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
# Ensure user can only have one rating per flavor
|
||||
flavor = serializer.validated_data['flavor']
|
||||
if Rating.objects.filter(user=self.request.user, flavor=flavor).exists():
|
||||
raise serializers.ValidationError('You have already rated this flavor.')
|
||||
rating = serializer.save(user=self.request.user)
|
||||
# Parse mentions in the rating comment
|
||||
if rating.comment:
|
||||
parse_mentions(rating.comment, self.request.user, rating=rating)
|
||||
|
||||
def get_queryset(self):
|
||||
return Rating.objects.select_related('user', 'flavor', 'flavor__category').prefetch_related('replies', 'replies__user').order_by('-created_at')
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
|
||||
def feed(self, request):
|
||||
# Get ratings from people the user follows
|
||||
followed_users = request.user.following.all()
|
||||
feed_ratings = Rating.objects.filter(user__in=followed_users).select_related('user', 'flavor', 'flavor__category').prefetch_related('replies', 'replies__user').order_by('-created_at')
|
||||
|
||||
paginator = FeedPagination()
|
||||
page = paginator.paginate_queryset(feed_ratings, request)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return paginator.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(feed_ratings, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def check_object_permissions(self, request, obj):
|
||||
super().check_object_permissions(request, obj)
|
||||
if request.method in ['PUT', 'PATCH', 'DELETE'] and obj.user != request.user and not request.user.is_superuser:
|
||||
self.permission_denied(request, message='You cannot edit/delete this rating.')
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
|
||||
def recent(self, request):
|
||||
recent_ratings = Rating.objects.filter(comment__isnull=False).exclude(comment='').select_related('user', 'flavor').order_by('-created_at')[:10]
|
||||
serializer = self.get_serializer(recent_ratings, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def reply(self, request, pk=None):
|
||||
rating = self.get_object()
|
||||
text = request.data.get('text')
|
||||
if not text:
|
||||
return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
reply = Reply.objects.create(user=request.user, rating=rating, text=text)
|
||||
|
||||
# Notify the rating author if it's not the same person
|
||||
if rating.user != request.user:
|
||||
Notification.objects.create(
|
||||
recipient=rating.user,
|
||||
actor=request.user,
|
||||
notification_type='reply',
|
||||
rating=rating,
|
||||
reply=reply
|
||||
)
|
||||
|
||||
# Parse mentions in the reply text
|
||||
parse_mentions(text, request.user, rating=rating, reply=reply)
|
||||
|
||||
return Response(ReplySerializer(reply, context={'request': request}).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
class ReplyViewSet(viewsets.ModelViewSet):
|
||||
queryset = Reply.objects.all()
|
||||
serializer_class = ReplySerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return Reply.objects.select_related('user', 'rating').order_by('created_at')
|
||||
|
||||
def perform_update(self, serializer):
|
||||
reply = self.get_object()
|
||||
if reply.user != self.request.user and not self.request.user.is_superuser:
|
||||
raise permissions.PermissionDenied("You cannot edit this reply.")
|
||||
new_reply = serializer.save()
|
||||
# Parse mentions in the updated text
|
||||
parse_mentions(new_reply.text, self.request.user, rating=new_reply.rating, reply=new_reply)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if instance.user != self.request.user and not self.request.user.is_superuser:
|
||||
raise permissions.PermissionDenied("You cannot delete this reply.")
|
||||
instance.delete()
|
||||
|
||||
class NotificationViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = NotificationSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
pagination_class = None
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.user.notifications.select_related('actor', 'rating__flavor', 'reply__rating__flavor').order_by('-created_at')
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def mark_all_read(self, request):
|
||||
request.user.notifications.filter(is_read=False).update(is_read=True)
|
||||
return Response({'status': 'all read'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def mark_read(self, request, pk=None):
|
||||
notification = self.get_object()
|
||||
notification.is_read = True
|
||||
notification.save()
|
||||
return Response({'status': 'marked read'})
|
||||
|
||||
class UserViewSet(viewsets.ModelViewSet):
|
||||
queryset = User.objects.all()
|
||||
serializer_class = UserSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ['signup', 'verify_signup', 'request_password_reset', 'complete_password_reset']:
|
||||
return [permissions.AllowAny()]
|
||||
return super().get_permissions()
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
|
||||
def following_list(self, request):
|
||||
following = request.user.following.all()
|
||||
serializer = self.get_serializer(following, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
@method_decorator(ratelimit(key='ip', rate='5/h', method='POST', block=True))
|
||||
def signup(self, request):
|
||||
username = request.data.get('username')
|
||||
email = request.data.get('email')
|
||||
password = request.data.get('password')
|
||||
if not username or not email or not password:
|
||||
return Response({'error': 'Username, email and password required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if User.objects.filter(username=username).exists():
|
||||
return Response({'error': 'Username already exists'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if User.objects.filter(email=email).exists():
|
||||
return Response({'error': 'Email already exists'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
import secrets
|
||||
import string
|
||||
code = ''.join(secrets.choice(string.digits) for _ in range(6))
|
||||
|
||||
user = User.objects.create_user(
|
||||
username=username,
|
||||
email=email,
|
||||
password=password,
|
||||
is_active=False,
|
||||
email_confirmation_code=code
|
||||
)
|
||||
|
||||
verification_link = f"{settings.FRONTEND_URL}/verify-email?username={username}&code={code}"
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
'Verify your Holy Flavors account',
|
||||
f'Hi {username},\n\nYour verification code is: {code}\n\nAlternatively, you can complete your registration by clicking the link below:\n{verification_link}\n\nWelcome to the archive!',
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[email],
|
||||
fail_silently=False,
|
||||
)
|
||||
except Exception as e:
|
||||
# If email fails for any reason, delete the user so they can try again
|
||||
user.delete()
|
||||
return Response(
|
||||
{'error': f'Failed to send verification email. Please check your SMTP settings in the .env file. The server error was: {e}'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
return Response({'status': 'User created, please verify your email', 'username': username, 'email': email}, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def resend_verification(self, request):
|
||||
username = request.data.get('username')
|
||||
if not username:
|
||||
return Response({'error': 'Username required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
user = User.objects.get(username=username, is_active=False)
|
||||
except User.DoesNotExist:
|
||||
return Response({'error': 'User not found or already active'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
import secrets
|
||||
import string
|
||||
code = ''.join(secrets.choice(string.digits) for _ in range(6))
|
||||
user.email_confirmation_code = code
|
||||
user.save()
|
||||
|
||||
verification_link = f"{settings.FRONTEND_URL}/verify-email?username={user.username}&code={code}"
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
'Verify your Holy Flavors account',
|
||||
f'Hi {user.username},\n\nYour new verification code is: {code}\n\nAlternatively, you can complete your registration by clicking the link below:\n{verification_link}',
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[user.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{'error': f'Failed to send email: {e}'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
return Response({'status': 'Verification code resent!'})
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def verify_signup(self, request):
|
||||
username = request.data.get('username')
|
||||
code = request.data.get('code')
|
||||
try:
|
||||
user = User.objects.get(username=username, is_active=False)
|
||||
except User.DoesNotExist:
|
||||
return Response({'error': 'User not found or already active'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if user.email_confirmation_code == code:
|
||||
user.is_active = True
|
||||
user.email_confirmation_code = None
|
||||
user.save()
|
||||
return Response({'status': 'Account verified successfully!'})
|
||||
else:
|
||||
return Response({'error': 'Invalid verification code'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny], url_path='profile/(?P<username>[^/.]+)')
|
||||
def public_profile(self, request, username=None):
|
||||
try:
|
||||
user = User.objects.get(username=username)
|
||||
except User.DoesNotExist:
|
||||
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
ratings = user.ratings.select_related('flavor', 'flavor__category').order_by('-score')
|
||||
comments = user.profile_comments.select_related('author').order_by('-created_at')
|
||||
|
||||
followers = user.followers.all()
|
||||
following = user.following.all()
|
||||
|
||||
return Response({
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'theme': user.theme,
|
||||
'avatar': request.build_absolute_uri(user.avatar.url) if user.avatar else None,
|
||||
'following_count': following.count(),
|
||||
'followers_count': followers.count(),
|
||||
'is_following': request.user.following.filter(pk=user.pk).exists() if request.user.is_authenticated else False,
|
||||
'ratings': RatingSerializer(ratings, many=True, context={'request': request}).data,
|
||||
'comments': ProfileCommentSerializer(comments, many=True, context={'request': request}).data,
|
||||
'followers': UserSerializer(followers, many=True, context={'request': request}).data,
|
||||
'following': UserSerializer(following, many=True, context={'request': request}).data
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def add_comment(self, request, pk=None):
|
||||
profile_owner = self.get_object()
|
||||
text = request.data.get('text')
|
||||
if not text:
|
||||
return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
comment = ProfileComment.objects.create(
|
||||
profile_owner=profile_owner,
|
||||
author=request.user,
|
||||
text=text
|
||||
)
|
||||
|
||||
# Notify profile owner
|
||||
if profile_owner != request.user:
|
||||
Notification.objects.create(
|
||||
recipient=profile_owner,
|
||||
actor=request.user,
|
||||
notification_type='profile_comment',
|
||||
profile_comment=comment
|
||||
)
|
||||
|
||||
return Response(ProfileCommentSerializer(comment, context={'request': request}).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['delete'], url_path='delete_comment/(?P<comment_id>[^/.]+)', permission_classes=[permissions.IsAuthenticated])
|
||||
def delete_comment(self, request, pk=None, comment_id=None):
|
||||
profile_owner = self.get_object()
|
||||
try:
|
||||
comment = ProfileComment.objects.get(pk=comment_id, profile_owner=profile_owner)
|
||||
except ProfileComment.DoesNotExist:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if comment.author != request.user and not request.user.is_superuser and profile_owner != request.user:
|
||||
return Response(status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
comment.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def me(self, request):
|
||||
log_user_ip(request.user, request)
|
||||
serializer = self.get_serializer(request.user)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['patch'])
|
||||
def update_preferences(self, request):
|
||||
user = request.user
|
||||
theme = request.data.get('theme')
|
||||
language = request.data.get('language')
|
||||
banner_id = request.data.get('selected_banner')
|
||||
|
||||
updated = False
|
||||
if theme in dict(User.THEME_CHOICES):
|
||||
user.theme = theme
|
||||
updated = True
|
||||
if language in dict(User.LANGUAGE_CHOICES):
|
||||
user.language = language
|
||||
updated = True
|
||||
|
||||
if banner_id is not None:
|
||||
if banner_id == "":
|
||||
user.selected_banner = None
|
||||
updated = True
|
||||
else:
|
||||
try:
|
||||
banner = Banner.objects.get(pk=banner_id, is_enabled=True)
|
||||
user.selected_banner = banner
|
||||
updated = True
|
||||
except Banner.DoesNotExist:
|
||||
return Response({'error': 'Invalid or disabled banner'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if updated:
|
||||
user.save()
|
||||
return Response({
|
||||
'status': 'preferences updated',
|
||||
'theme': user.theme,
|
||||
'language': user.language,
|
||||
'selected_banner': user.selected_banner.id if user.selected_banner else None
|
||||
})
|
||||
return Response({'error': 'No valid updates provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def update_avatar(self, request):
|
||||
user = request.user
|
||||
avatar = request.FILES.get('avatar')
|
||||
if not avatar:
|
||||
return Response({'error': 'No avatar provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Check size (2MB = 2 * 1024 * 1024 bytes)
|
||||
if avatar.size > 2 * 1024 * 1024:
|
||||
return Response({'error': 'File size exceeds 2MB limit'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user.avatar = avatar
|
||||
user.save()
|
||||
serializer = self.get_serializer(user)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def change_password(self, request):
|
||||
user = request.user
|
||||
old_password = request.data.get('old_password')
|
||||
new_password = request.data.get('new_password')
|
||||
if not user.check_password(old_password):
|
||||
return Response({'error': 'Wrong old password'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
return Response({'status': 'password changed'})
|
||||
|
||||
@action(detail=False, methods=['patch'])
|
||||
def update_profile(self, request):
|
||||
user = request.user
|
||||
username = request.data.get('username')
|
||||
email = request.data.get('email')
|
||||
|
||||
message = ""
|
||||
if username and username != user.username:
|
||||
if User.objects.filter(username=username).exists():
|
||||
return Response({'error': 'Username already taken'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
user.username = username
|
||||
|
||||
if email and email != user.email:
|
||||
import secrets
|
||||
import string
|
||||
code = ''.join(secrets.choice(string.digits) for _ in range(6))
|
||||
user.pending_email = email
|
||||
user.email_confirmation_code = code
|
||||
send_mail(
|
||||
'Confirm your new email',
|
||||
f'Hi {user.username},\n\nYour confirmation code is: {code}',
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[email],
|
||||
fail_silently=False,
|
||||
)
|
||||
message = " Confirmation code sent to new email."
|
||||
|
||||
user.save()
|
||||
serializer = self.get_serializer(user)
|
||||
data = serializer.data
|
||||
if message:
|
||||
data['message'] = message
|
||||
return Response(data)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def follow(self, request, pk=None):
|
||||
user_to_follow = self.get_object()
|
||||
if user_to_follow == request.user:
|
||||
return Response({'error': 'You cannot follow yourself'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not request.user.following.filter(pk=user_to_follow.pk).exists():
|
||||
request.user.following.add(user_to_follow)
|
||||
# Notify the user being followed
|
||||
Notification.objects.create(
|
||||
recipient=user_to_follow,
|
||||
actor=request.user,
|
||||
notification_type='follow'
|
||||
)
|
||||
|
||||
return Response({'status': 'followed'})
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def unfollow(self, request, pk=None):
|
||||
user_to_unfollow = self.get_object()
|
||||
request.user.following.remove(user_to_unfollow)
|
||||
return Response({'status': 'unfollowed'})
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def confirm_email(self, request):
|
||||
user = request.user
|
||||
code = request.data.get('code')
|
||||
if not user.email_confirmation_code or code != user.email_confirmation_code:
|
||||
return Response({'error': 'Invalid or expired code'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user.email = user.pending_email
|
||||
user.pending_email = None
|
||||
user.email_confirmation_code = None
|
||||
user.save()
|
||||
return Response({'status': 'Email confirmed', 'email': user.email})
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
@method_decorator(ratelimit(key='ip', rate='3/h', method='POST', block=True))
|
||||
def request_password_reset(self, request):
|
||||
email = request.data.get('email')
|
||||
try:
|
||||
user = User.objects.get(email=email, is_active=True)
|
||||
except User.DoesNotExist:
|
||||
return Response({'status': 'If an account exists with this email, a reset code has been sent.'})
|
||||
|
||||
import secrets
|
||||
import string
|
||||
code = ''.join(secrets.choice(string.digits) for _ in range(6))
|
||||
user.email_confirmation_code = code
|
||||
user.save()
|
||||
|
||||
send_mail(
|
||||
'Password Reset Request',
|
||||
f'Hi {user.username},\n\nYour password reset code is: {code}',
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[email],
|
||||
fail_silently=False,
|
||||
)
|
||||
return Response({'status': 'If an account exists with this email, a reset code has been sent.'})
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
@method_decorator(ratelimit(key='ip', rate='5/h', method='POST', block=True))
|
||||
def complete_password_reset(self, request):
|
||||
email = request.data.get('email')
|
||||
code = request.data.get('code')
|
||||
new_password = request.data.get('password')
|
||||
|
||||
try:
|
||||
user = User.objects.get(email=email, email_confirmation_code=code)
|
||||
except User.DoesNotExist:
|
||||
return Response({'error': 'Invalid email or code'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user.set_password(new_password)
|
||||
user.email_confirmation_code = None
|
||||
user.save()
|
||||
return Response({'status': 'Password reset successful!'})
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
@method_decorator(ratelimit(key='user', rate='3/h', method='POST', block=True))
|
||||
def request_account_deletion(self, request):
|
||||
import secrets
|
||||
import string
|
||||
code = ''.join(secrets.choice(string.digits) for _ in range(6))
|
||||
|
||||
user = request.user
|
||||
user.email_confirmation_code = code
|
||||
user.save()
|
||||
|
||||
send_mail(
|
||||
'Confirm Account Deletion - Holy Flavors Archive',
|
||||
f'Hi {user.username},\n\nYou requested to delete your account. This action is permanent.\n\nYour deletion code is: {code}',
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[user.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
return Response({'status': 'Code sent to your email'})
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def confirm_account_deletion(self, request):
|
||||
user = request.user
|
||||
code = request.data.get('code')
|
||||
if not user.email_confirmation_code or code != user.email_confirmation_code:
|
||||
return Response({'error': 'Invalid or expired code'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user.delete()
|
||||
return Response({'status': 'Account deleted'})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def dashboard(self, request):
|
||||
user = request.user
|
||||
rated_ids = user.ratings.values_list('flavor_id', flat=True)
|
||||
followed_users = user.following.all()
|
||||
|
||||
# Missing flavors with community avg and followed circle avg
|
||||
missing_flavors = Flavor.objects.exclude(id__in=rated_ids) \
|
||||
.exclude(category__name='Packs and other') \
|
||||
.select_related('category') \
|
||||
.annotate(
|
||||
average_rating=Avg('ratings__score'),
|
||||
followed_average_rating=Avg('ratings__score', filter=Q(ratings__user__in=followed_users))
|
||||
).order_by('category__name', 'name')
|
||||
|
||||
rated_flavors = user.ratings.select_related('flavor', 'flavor__category') \
|
||||
.prefetch_related('replies', 'replies__user') \
|
||||
.order_by('-created_at')
|
||||
|
||||
return Response({
|
||||
'user': UserSerializer(user).data,
|
||||
'rated_count': rated_flavors.count(),
|
||||
'missing_count': missing_flavors.count(),
|
||||
'missing_flavors': FlavorSerializer(missing_flavors, many=True, context={'request': request}).data,
|
||||
'my_ratings': RatingSerializer(rated_flavors, many=True, context={'request': request}).data
|
||||
})
|
||||
class TicketViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = TicketSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_superuser:
|
||||
return Ticket.objects.all().prefetch_related('messages', 'messages__user').order_by('-updated_at')
|
||||
return Ticket.objects.filter(user=self.request.user).prefetch_related('messages', 'messages__user').order_by('-updated_at')
|
||||
|
||||
def perform_create(self, serializer):
|
||||
ticket = serializer.save(user=self.request.user)
|
||||
# Notify all superusers about new ticket
|
||||
admins = User.objects.filter(is_superuser=True).exclude(pk=self.request.user.pk)
|
||||
for admin in admins:
|
||||
Notification.objects.create(
|
||||
recipient=admin,
|
||||
actor=self.request.user,
|
||||
notification_type='ticket_new',
|
||||
ticket=ticket
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def add_message(self, request, pk=None):
|
||||
ticket = self.get_object()
|
||||
text = request.data.get('text')
|
||||
if not text:
|
||||
return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
msg = TicketMessage.objects.create(ticket=ticket, user=request.user, text=text)
|
||||
|
||||
# Notification logic
|
||||
if request.user.is_superuser:
|
||||
# Notify the user who opened the ticket
|
||||
if ticket.user != request.user:
|
||||
Notification.objects.create(
|
||||
recipient=ticket.user,
|
||||
actor=request.user,
|
||||
notification_type='ticket_reply',
|
||||
ticket=ticket
|
||||
)
|
||||
else:
|
||||
# Notify all superusers
|
||||
admins = User.objects.filter(is_superuser=True).exclude(pk=request.user.pk)
|
||||
for admin in admins:
|
||||
Notification.objects.create(
|
||||
recipient=admin,
|
||||
actor=request.user,
|
||||
notification_type='ticket_reply',
|
||||
ticket=ticket
|
||||
)
|
||||
|
||||
return Response(TicketMessageSerializer(msg).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAdminUser])
|
||||
def update_status(self, request, pk=None):
|
||||
ticket = self.get_object()
|
||||
new_status = request.data.get('status')
|
||||
if new_status in dict(Ticket.STATUS_CHOICES):
|
||||
ticket.status = new_status
|
||||
ticket.save()
|
||||
return Response({'status': 'updated'})
|
||||
return Response({'error': 'Invalid status'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
if not request.user.is_superuser:
|
||||
raise permissions.PermissionDenied("Only admins can delete tickets.")
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
class BannerViewSet(viewsets.ModelViewSet):
|
||||
queryset = Banner.objects.all()
|
||||
serializer_class = BannerSerializer
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
pagination_class = None
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_authenticated and self.request.user.is_superuser:
|
||||
return Banner.objects.all()
|
||||
return Banner.objects.filter(is_enabled=True)
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ['list', 'retrieve', 'active']:
|
||||
return [permissions.AllowAny()]
|
||||
return [permissions.IsAdminUser()]
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def active(self, request):
|
||||
# Determine which banner to show
|
||||
# Priority:
|
||||
# 1. Requested user's preference (if username provided in query)
|
||||
# 2. Logged in user's preference
|
||||
# 3. Global default
|
||||
|
||||
username = request.query_params.get('username')
|
||||
user = None
|
||||
if username:
|
||||
try:
|
||||
user = User.objects.get(username=username)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
|
||||
if not user and request.user.is_authenticated:
|
||||
user = request.user
|
||||
|
||||
if user and user.selected_banner and user.selected_banner.is_enabled:
|
||||
serializer = self.get_serializer(user.selected_banner)
|
||||
return Response(serializer.data)
|
||||
|
||||
active_banner = Banner.objects.filter(is_active=True).first()
|
||||
if active_banner:
|
||||
serializer = self.get_serializer(active_banner)
|
||||
return Response(serializer.data)
|
||||
return Response(None)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def activate(self, request, pk=None):
|
||||
banner = self.get_object()
|
||||
banner.is_active = True
|
||||
banner.is_enabled = True # Active banner must be enabled
|
||||
banner.save()
|
||||
return Response({'status': 'banner set as global default'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def toggle_enabled(self, request, pk=None):
|
||||
banner = self.get_object()
|
||||
if banner.is_active:
|
||||
return Response({'error': 'Cannot disable the global default banner.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
banner.is_enabled = not banner.is_enabled
|
||||
banner.save()
|
||||
return Response({'status': 'enabled' if banner.is_enabled else 'disabled'})
|
||||
|
||||
class AdminViewSet(viewsets.ViewSet):
|
||||
permission_classes = [permissions.IsAdminUser]
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def stats(self, request):
|
||||
return Response({
|
||||
'total_users': User.objects.count(),
|
||||
'total_ratings': Rating.objects.count(),
|
||||
'total_replies': Reply.objects.count(),
|
||||
'open_tickets': Ticket.objects.filter(status='open').count(),
|
||||
'email_config': {
|
||||
'host': getattr(settings, 'EMAIL_HOST', 'None'),
|
||||
'port': getattr(settings, 'EMAIL_PORT', 'None'),
|
||||
'use_tls': getattr(settings, 'EMAIL_USE_TLS', False),
|
||||
'use_ssl': getattr(settings, 'EMAIL_USE_SSL', False),
|
||||
'skip_verify': getattr(settings, 'EMAIL_SKIP_CERT_VERIFICATION', False),
|
||||
},
|
||||
'server_info': {
|
||||
'debug': settings.DEBUG,
|
||||
'allowed_hosts': settings.ALLOWED_HOSTS,
|
||||
'frontend_url': getattr(settings, 'FRONTEND_URL', 'Not set'),
|
||||
'media_root': str(settings.MEDIA_ROOT),
|
||||
'static_root': str(settings.STATIC_ROOT),
|
||||
}
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['get', 'patch'])
|
||||
def config(self, request):
|
||||
config = SystemConfig.get_solo()
|
||||
if request.method == 'PATCH':
|
||||
serializer = SystemConfigSerializer(config, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
|
||||
serializer = SystemConfigSerializer(config)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def jobs(self, request):
|
||||
# Ensure all jobs exist
|
||||
for job_type, label in Job.JOB_TYPES:
|
||||
Job.objects.get_or_create(name=job_type)
|
||||
|
||||
jobs = Job.objects.all().order_by('name')
|
||||
serializer = JobSerializer(jobs, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def trigger_job(self, request, pk=None):
|
||||
try:
|
||||
job = Job.objects.get(pk=pk)
|
||||
job.status = 'pending'
|
||||
job.save()
|
||||
return Response({'status': 'Job queued'})
|
||||
except Job.DoesNotExist:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@action(detail=True, methods=['patch'])
|
||||
def update_job_schedule(self, request, pk=None):
|
||||
try:
|
||||
job = Job.objects.get(pk=pk)
|
||||
interval = request.data.get('interval_hours')
|
||||
next_run = request.data.get('next_run')
|
||||
|
||||
if interval is not None:
|
||||
job.interval_hours = int(interval)
|
||||
|
||||
if next_run is not None:
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from django.utils import timezone
|
||||
parsed_dt = parse_datetime(next_run)
|
||||
if parsed_dt:
|
||||
# If it's naive (has no timezone info), assume it's local time
|
||||
if timezone.is_naive(parsed_dt):
|
||||
job.next_run = timezone.make_aware(parsed_dt, timezone.get_current_timezone())
|
||||
else:
|
||||
job.next_run = parsed_dt
|
||||
elif interval is not None and job.interval_hours > 0 and not job.next_run:
|
||||
from django.utils import timezone
|
||||
job.next_run = timezone.now()
|
||||
|
||||
job.save()
|
||||
return Response(JobSerializer(job).data)
|
||||
except Job.DoesNotExist:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def send_test_email(self, request):
|
||||
try:
|
||||
send_mail(
|
||||
'Holy Flavors Admin Test Email',
|
||||
f'This is a test email sent to {request.user.email} from the Holy Flavors Admin Interface.',
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[request.user.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
return Response({'status': 'Test email sent!'})
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def users(self, request):
|
||||
users = User.objects.all().prefetch_related('ips').order_by('-date_joined')
|
||||
return Response(AdminUserListSerializer(users, many=True).data)
|
||||
|
||||
@action(detail=True, methods=['get', 'patch', 'delete'])
|
||||
def user_detail(self, request, pk=None):
|
||||
try:
|
||||
user = User.objects.get(pk=pk)
|
||||
except User.DoesNotExist:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if request.method == 'GET':
|
||||
return Response(AdminUserDetailSerializer(user, context={'request': request}).data)
|
||||
elif request.method == 'PATCH':
|
||||
is_active = request.data.get('is_active')
|
||||
if is_active is not None:
|
||||
user.is_active = is_active
|
||||
user.save()
|
||||
return Response(AdminUserDetailSerializer(user, context={'request': request}).data)
|
||||
elif request.method == 'DELETE':
|
||||
user.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,20 @@
|
||||
from api.views._pagination import FeedPagination
|
||||
from api.views.admin import AdminViewSet
|
||||
from api.views.banner import BannerViewSet
|
||||
from api.views.flavor import CategoryViewSet, FlavorViewSet
|
||||
from api.views.rating import RatingViewSet, ReplyViewSet
|
||||
from api.views.support import TicketViewSet
|
||||
from api.views.user import NotificationViewSet, UserViewSet
|
||||
|
||||
__all__ = [
|
||||
"AdminViewSet",
|
||||
"BannerViewSet",
|
||||
"CategoryViewSet",
|
||||
"FeedPagination",
|
||||
"FlavorViewSet",
|
||||
"NotificationViewSet",
|
||||
"RatingViewSet",
|
||||
"ReplyViewSet",
|
||||
"TicketViewSet",
|
||||
"UserViewSet",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
|
||||
class FeedPagination(PageNumberPagination):
|
||||
page_size = 10
|
||||
@@ -0,0 +1,145 @@
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
from rest_framework import permissions, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from api.models import Job, Rating, Reply, SystemConfig, Ticket, User
|
||||
from api.serializers import (
|
||||
AdminUserDetailSerializer,
|
||||
AdminUserListSerializer,
|
||||
JobSerializer,
|
||||
SystemConfigSerializer,
|
||||
)
|
||||
|
||||
|
||||
class AdminViewSet(viewsets.ViewSet):
|
||||
permission_classes = [permissions.IsAdminUser]
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def stats(self, request: Request) -> Response:
|
||||
return Response(
|
||||
{
|
||||
"total_users": User.objects.count(),
|
||||
"total_ratings": Rating.objects.count(),
|
||||
"total_replies": Reply.objects.count(),
|
||||
"open_tickets": Ticket.objects.filter(status="open").count(),
|
||||
"email_config": {
|
||||
"host": getattr(settings, "EMAIL_HOST", "None"),
|
||||
"port": getattr(settings, "EMAIL_PORT", "None"),
|
||||
"use_tls": getattr(settings, "EMAIL_USE_TLS", False),
|
||||
"use_ssl": getattr(settings, "EMAIL_USE_SSL", False),
|
||||
"skip_verify": getattr(settings, "EMAIL_SKIP_CERT_VERIFICATION", False),
|
||||
},
|
||||
"server_info": {
|
||||
"debug": settings.DEBUG,
|
||||
"allowed_hosts": settings.ALLOWED_HOSTS,
|
||||
"frontend_url": getattr(settings, "FRONTEND_URL", "Not set"),
|
||||
"media_root": str(settings.MEDIA_ROOT),
|
||||
"static_root": str(settings.STATIC_ROOT),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["get", "patch"])
|
||||
def config(self, request: Request) -> Response:
|
||||
config = SystemConfig.get_solo()
|
||||
if request.method == "PATCH":
|
||||
serializer = SystemConfigSerializer(config, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
|
||||
return Response(SystemConfigSerializer(config).data)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def jobs(self, request: Request) -> Response:
|
||||
for job_type, _ in Job.JOB_TYPES:
|
||||
Job.objects.get_or_create(name=job_type)
|
||||
|
||||
jobs = Job.objects.all().order_by("name")
|
||||
return Response(JobSerializer(jobs, many=True).data)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def trigger_job(self, request: Request, pk=None) -> Response:
|
||||
try:
|
||||
job = Job.objects.get(pk=pk)
|
||||
job.status = "pending"
|
||||
job.save()
|
||||
return Response({"status": "Job queued"})
|
||||
except Job.DoesNotExist:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@action(detail=True, methods=["patch"])
|
||||
def update_job_schedule(self, request: Request, pk=None) -> Response:
|
||||
try:
|
||||
job = Job.objects.get(pk=pk)
|
||||
interval = request.data.get("interval_hours")
|
||||
next_run = request.data.get("next_run")
|
||||
|
||||
if interval is not None:
|
||||
job.interval_hours = int(interval)
|
||||
|
||||
if next_run is not None:
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
||||
parsed_dt = parse_datetime(next_run)
|
||||
if parsed_dt:
|
||||
if timezone.is_naive(parsed_dt):
|
||||
job.next_run = timezone.make_aware(
|
||||
parsed_dt, timezone.get_current_timezone()
|
||||
)
|
||||
else:
|
||||
job.next_run = parsed_dt
|
||||
elif interval is not None and job.interval_hours > 0 and not job.next_run:
|
||||
from django.utils import timezone
|
||||
|
||||
job.next_run = timezone.now()
|
||||
|
||||
job.save()
|
||||
return Response(JobSerializer(job).data)
|
||||
except Job.DoesNotExist:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@action(detail=False, methods=["post"])
|
||||
def send_test_email(self, request: Request) -> Response:
|
||||
try:
|
||||
send_mail(
|
||||
"Holy Flavors Admin Test Email",
|
||||
(
|
||||
f"This is a test email sent to {request.user.email} from the "
|
||||
f"Holy Flavors Admin Interface."
|
||||
),
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[request.user.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
return Response({"status": "Test email sent!"})
|
||||
except Exception as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def users(self, request: Request) -> Response:
|
||||
users = User.objects.all().prefetch_related("ips").order_by("-date_joined")
|
||||
return Response(AdminUserListSerializer(users, many=True).data)
|
||||
|
||||
@action(detail=True, methods=["get", "patch", "delete"])
|
||||
def user_detail(self, request: Request, pk=None) -> Response:
|
||||
try:
|
||||
user = User.objects.get(pk=pk)
|
||||
except User.DoesNotExist:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if request.method == "GET":
|
||||
return Response(AdminUserDetailSerializer(user, context={"request": request}).data)
|
||||
elif request.method == "PATCH":
|
||||
is_active = request.data.get("is_active")
|
||||
if is_active is not None:
|
||||
user.is_active = is_active
|
||||
user.save()
|
||||
return Response(AdminUserDetailSerializer(user, context={"request": request}).data)
|
||||
elif request.method == "DELETE":
|
||||
user.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,65 @@
|
||||
from rest_framework import permissions, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from api.models import Banner, User
|
||||
from api.serializers import BannerSerializer
|
||||
|
||||
|
||||
class BannerViewSet(viewsets.ModelViewSet):
|
||||
queryset = Banner.objects.all()
|
||||
serializer_class = BannerSerializer
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
pagination_class = None
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_authenticated and self.request.user.is_superuser:
|
||||
return Banner.objects.all()
|
||||
return Banner.objects.filter(is_enabled=True)
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ["list", "retrieve", "active"]:
|
||||
return [permissions.AllowAny()]
|
||||
return [permissions.IsAdminUser()]
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def active(self, request: Request) -> Response:
|
||||
username = request.query_params.get("username")
|
||||
user = None
|
||||
if username:
|
||||
try:
|
||||
user = User.objects.get(username=username)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
|
||||
if not user and request.user.is_authenticated:
|
||||
user = request.user
|
||||
|
||||
if user and user.selected_banner and user.selected_banner.is_enabled:
|
||||
return Response(self.get_serializer(user.selected_banner).data)
|
||||
|
||||
active_banner = Banner.objects.filter(is_active=True).first()
|
||||
if active_banner:
|
||||
return Response(self.get_serializer(active_banner).data)
|
||||
return Response(None)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def activate(self, request: Request, pk=None) -> Response:
|
||||
banner = self.get_object()
|
||||
banner.is_active = True
|
||||
banner.is_enabled = True
|
||||
banner.save()
|
||||
return Response({"status": "banner set as global default"})
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def toggle_enabled(self, request: Request, pk=None) -> Response:
|
||||
banner = self.get_object()
|
||||
if banner.is_active:
|
||||
return Response(
|
||||
{"error": "Cannot disable the global default banner."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
banner.is_enabled = not banner.is_enabled
|
||||
banner.save()
|
||||
return Response({"status": "enabled" if banner.is_enabled else "disabled"})
|
||||
@@ -0,0 +1,105 @@
|
||||
from django.db.models import Avg
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import filters, permissions, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from api.models import Category, Flavor
|
||||
from api.serializers import CategorySerializer, FlavorSerializer
|
||||
from api.services.search import (
|
||||
extract_category_slug,
|
||||
filter_flavors_by_query,
|
||||
score_relevance,
|
||||
)
|
||||
|
||||
|
||||
class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = Category.objects.all()
|
||||
serializer_class = CategorySerializer
|
||||
|
||||
|
||||
class FlavorViewSet(viewsets.ModelViewSet):
|
||||
queryset = Flavor.objects.annotate(average_rating=Avg("ratings__score")).order_by(
|
||||
"-average_rating"
|
||||
)
|
||||
serializer_class = FlavorSerializer
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
|
||||
filterset_fields = ["category", "category__slug"]
|
||||
search_fields = ["name", "description"]
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
|
||||
def get_queryset(self):
|
||||
qs = (
|
||||
Flavor.objects.select_related("category")
|
||||
.annotate(average_rating=Avg("ratings__score"))
|
||||
.order_by("-average_rating")
|
||||
.distinct()
|
||||
)
|
||||
search_query = self.request.query_params.get("search", "")
|
||||
slug, _ = extract_category_slug(search_query)
|
||||
if slug:
|
||||
qs = qs.filter(category__slug=slug)
|
||||
return qs
|
||||
|
||||
@action(detail=False, methods=["get"], permission_classes=[permissions.AllowAny])
|
||||
def search(self, request: Request) -> Response:
|
||||
query = request.query_params.get("q", "").lower().strip()
|
||||
if not query:
|
||||
return Response([])
|
||||
|
||||
flavors = Flavor.objects.select_related("category").distinct()
|
||||
flavors = filter_flavors_by_query(flavors, query)
|
||||
|
||||
_, remaining = extract_category_slug(query)
|
||||
seen_keys: set[str] = set()
|
||||
results: list[dict] = []
|
||||
|
||||
for f in flavors.order_by("name"):
|
||||
key = f"{f.name.strip().lower()}|{f.category.slug}"
|
||||
if key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
results.append(
|
||||
{
|
||||
"id": f.id,
|
||||
"name": f.name,
|
||||
"type": "flavor",
|
||||
"subtitle": f.category.name,
|
||||
"image_url": (
|
||||
request.build_absolute_uri(f.image.url) if f.image else f.image_url
|
||||
),
|
||||
"slug": None,
|
||||
"relevance": score_relevance(f.name, remaining),
|
||||
}
|
||||
)
|
||||
|
||||
results.sort(key=lambda x: (-x["relevance"], x["name"]))
|
||||
for r in results:
|
||||
del r["relevance"]
|
||||
return Response(results[:15])
|
||||
|
||||
@action(detail=False, methods=["get"], permission_classes=[permissions.AllowAny])
|
||||
def top(self, request: Request) -> Response:
|
||||
queryset = self.get_queryset().filter(ratings__isnull=False)
|
||||
category_slug = request.query_params.get("category")
|
||||
if category_slug:
|
||||
queryset = queryset.filter(category__slug=category_slug)
|
||||
top_flavors = queryset.distinct()[:10]
|
||||
return Response(self.get_serializer(top_flavors, many=True).data)
|
||||
|
||||
@action(detail=False, methods=["get"], permission_classes=[permissions.AllowAny])
|
||||
def newest(self, request: Request) -> Response:
|
||||
newest_flavors = Flavor.objects.select_related("category").order_by("-created_at")[:10]
|
||||
return Response(self.get_serializer(newest_flavors, many=True).data)
|
||||
|
||||
@action(detail=False, methods=["get"], permission_classes=[permissions.IsAuthenticated])
|
||||
def followed_top(self, request: Request) -> Response:
|
||||
followed_users = request.user.following.all()
|
||||
top_flavors = (
|
||||
Flavor.objects.filter(ratings__user__in=followed_users)
|
||||
.annotate(average_rating=Avg("ratings__score"))
|
||||
.order_by("-average_rating")
|
||||
.distinct()[:10]
|
||||
)
|
||||
return Response(self.get_serializer(top_flavors, many=True).data)
|
||||
@@ -0,0 +1,120 @@
|
||||
from django.utils.decorators import method_decorator
|
||||
from django_ratelimit.decorators import ratelimit
|
||||
from rest_framework import permissions, serializers, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from api.models import Notification, Rating, Reply
|
||||
from api.serializers import RatingSerializer, ReplySerializer
|
||||
from api.services.mentions import parse_mentions
|
||||
from api.views._pagination import FeedPagination
|
||||
|
||||
|
||||
class RatingViewSet(viewsets.ModelViewSet):
|
||||
queryset = Rating.objects.all()
|
||||
serializer_class = RatingSerializer
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
|
||||
@method_decorator(ratelimit(key="user", rate="10/m", method="POST", block=True))
|
||||
def create(self, request, *args, **kwargs):
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
flavor = serializer.validated_data["flavor"]
|
||||
if Rating.objects.filter(user=self.request.user, flavor=flavor).exists():
|
||||
raise serializers.ValidationError("You have already rated this flavor.")
|
||||
rating = serializer.save(user=self.request.user)
|
||||
if rating.comment:
|
||||
parse_mentions(rating.comment, self.request.user, rating=rating)
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Rating.objects.select_related("user", "flavor", "flavor__category")
|
||||
.prefetch_related("replies", "replies__user")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["get"], permission_classes=[permissions.IsAuthenticated])
|
||||
def feed(self, request: Request) -> Response:
|
||||
followed_users = request.user.following.all()
|
||||
feed_ratings = (
|
||||
Rating.objects.filter(user__in=followed_users)
|
||||
.select_related("user", "flavor", "flavor__category")
|
||||
.prefetch_related("replies", "replies__user")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
paginator = FeedPagination()
|
||||
page = paginator.paginate_queryset(feed_ratings, request)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return paginator.get_paginated_response(serializer.data)
|
||||
|
||||
return Response(self.get_serializer(feed_ratings, many=True).data)
|
||||
|
||||
def check_object_permissions(self, request, obj):
|
||||
super().check_object_permissions(request, obj)
|
||||
if (
|
||||
request.method in ["PUT", "PATCH", "DELETE"]
|
||||
and obj.user != request.user
|
||||
and not request.user.is_superuser
|
||||
):
|
||||
self.permission_denied(request, message="You cannot edit/delete this rating.")
|
||||
|
||||
@action(detail=False, methods=["get"], permission_classes=[permissions.AllowAny])
|
||||
def recent(self, request: Request) -> Response:
|
||||
recent_ratings = (
|
||||
Rating.objects.filter(comment__isnull=False)
|
||||
.exclude(comment="")
|
||||
.select_related("user", "flavor")
|
||||
.order_by("-created_at")[:10]
|
||||
)
|
||||
return Response(self.get_serializer(recent_ratings, many=True).data)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated])
|
||||
def reply(self, request: Request, pk=None) -> Response:
|
||||
rating = self.get_object()
|
||||
text = request.data.get("text")
|
||||
if not text:
|
||||
return Response({"error": "Text is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
reply = Reply.objects.create(user=request.user, rating=rating, text=text)
|
||||
|
||||
if rating.user != request.user:
|
||||
Notification.objects.create(
|
||||
recipient=rating.user,
|
||||
actor=request.user,
|
||||
notification_type="reply",
|
||||
rating=rating,
|
||||
reply=reply,
|
||||
)
|
||||
|
||||
parse_mentions(text, request.user, rating=rating, reply=reply)
|
||||
|
||||
return Response(
|
||||
ReplySerializer(reply, context={"request": request}).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class ReplyViewSet(viewsets.ModelViewSet):
|
||||
queryset = Reply.objects.all()
|
||||
serializer_class = ReplySerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return Reply.objects.select_related("user", "rating").order_by("created_at")
|
||||
|
||||
def perform_update(self, serializer):
|
||||
reply = self.get_object()
|
||||
if reply.user != self.request.user and not self.request.user.is_superuser:
|
||||
raise PermissionDenied("You cannot edit this reply.")
|
||||
new_reply = serializer.save()
|
||||
parse_mentions(new_reply.text, self.request.user, rating=new_reply.rating, reply=new_reply)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if instance.user != self.request.user and not self.request.user.is_superuser:
|
||||
raise PermissionDenied("You cannot delete this reply.")
|
||||
instance.delete()
|
||||
@@ -0,0 +1,81 @@
|
||||
from rest_framework import permissions, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from api.models import Notification, Ticket, TicketMessage, User
|
||||
from api.serializers import TicketMessageSerializer, TicketSerializer
|
||||
|
||||
|
||||
class TicketViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = TicketSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_superuser:
|
||||
return (
|
||||
Ticket.objects.all()
|
||||
.prefetch_related("messages", "messages__user")
|
||||
.order_by("-updated_at")
|
||||
)
|
||||
return (
|
||||
Ticket.objects.filter(user=self.request.user)
|
||||
.prefetch_related("messages", "messages__user")
|
||||
.order_by("-updated_at")
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
ticket = serializer.save(user=self.request.user)
|
||||
admins = User.objects.filter(is_superuser=True).exclude(pk=self.request.user.pk)
|
||||
for admin in admins:
|
||||
Notification.objects.create(
|
||||
recipient=admin,
|
||||
actor=self.request.user,
|
||||
notification_type="ticket_new",
|
||||
ticket=ticket,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def add_message(self, request: Request, pk=None) -> Response:
|
||||
ticket = self.get_object()
|
||||
text = request.data.get("text")
|
||||
if not text:
|
||||
return Response({"error": "Text is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
msg = TicketMessage.objects.create(ticket=ticket, user=request.user, text=text)
|
||||
|
||||
if request.user.is_superuser:
|
||||
if ticket.user != request.user:
|
||||
Notification.objects.create(
|
||||
recipient=ticket.user,
|
||||
actor=request.user,
|
||||
notification_type="ticket_reply",
|
||||
ticket=ticket,
|
||||
)
|
||||
else:
|
||||
admins = User.objects.filter(is_superuser=True).exclude(pk=request.user.pk)
|
||||
for admin in admins:
|
||||
Notification.objects.create(
|
||||
recipient=admin,
|
||||
actor=request.user,
|
||||
notification_type="ticket_reply",
|
||||
ticket=ticket,
|
||||
)
|
||||
|
||||
return Response(TicketMessageSerializer(msg).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
|
||||
def update_status(self, request: Request, pk=None) -> Response:
|
||||
ticket = self.get_object()
|
||||
new_status = request.data.get("status")
|
||||
if new_status in dict(Ticket.STATUS_CHOICES):
|
||||
ticket.status = new_status
|
||||
ticket.save()
|
||||
return Response({"status": "updated"})
|
||||
return Response({"error": "Invalid status"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
if not request.user.is_superuser:
|
||||
raise PermissionDenied("Only admins can delete tickets.")
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
@@ -0,0 +1,546 @@
|
||||
import secrets
|
||||
import string
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.decorators import method_decorator
|
||||
from django_ratelimit.decorators import ratelimit
|
||||
from rest_framework import permissions, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from api.models import Banner, Flavor, Notification, ProfileComment, User
|
||||
from api.serializers import (
|
||||
FlavorSerializer,
|
||||
NotificationSerializer,
|
||||
ProfileCommentSerializer,
|
||||
RatingSerializer,
|
||||
UserSerializer,
|
||||
)
|
||||
from api.services.ip_logging import log_user_ip
|
||||
from api.tasks import send_email_task
|
||||
|
||||
|
||||
def _generate_code() -> str:
|
||||
return "".join(secrets.choice(string.digits) for _ in range(6))
|
||||
|
||||
|
||||
class UserViewSet(viewsets.ModelViewSet):
|
||||
queryset = User.objects.all()
|
||||
serializer_class = UserSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in [
|
||||
"signup",
|
||||
"verify_signup",
|
||||
"request_password_reset",
|
||||
"complete_password_reset",
|
||||
]:
|
||||
return [permissions.AllowAny()]
|
||||
return super().get_permissions()
|
||||
|
||||
@action(detail=False, methods=["get"], permission_classes=[permissions.IsAuthenticated])
|
||||
def following_list(self, request: Request) -> Response:
|
||||
following = request.user.following.all()
|
||||
return Response(self.get_serializer(following, many=True).data)
|
||||
|
||||
@action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny])
|
||||
@method_decorator(ratelimit(key="ip", rate="5/h", method="POST", block=True))
|
||||
def signup(self, request: Request) -> Response:
|
||||
username = request.data.get("username")
|
||||
email = request.data.get("email")
|
||||
password = request.data.get("password")
|
||||
if not username or not email or not password:
|
||||
return Response(
|
||||
{"error": "Username, email and password required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if User.objects.filter(username=username).exists():
|
||||
return Response(
|
||||
{"error": "Username already exists"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
if User.objects.filter(email=email).exists():
|
||||
return Response({"error": "Email already exists"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
code = _generate_code()
|
||||
user = User.objects.create_user(
|
||||
username=username,
|
||||
email=email,
|
||||
password=password,
|
||||
is_active=False,
|
||||
email_confirmation_code=code,
|
||||
)
|
||||
|
||||
verification_link = f"{settings.FRONTEND_URL}/verify-email?username={username}&code={code}"
|
||||
|
||||
try:
|
||||
send_email_task.delay(
|
||||
subject="Verify your Holy Flavors account",
|
||||
message=(
|
||||
f"Hi {username},\n\nYour verification code is: {code}\n\n"
|
||||
f"Alternatively, you can complete your registration by clicking the link below:\n"
|
||||
f"{verification_link}\n\nWelcome to the archive!"
|
||||
),
|
||||
recipient_list=[email],
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
)
|
||||
except Exception as e:
|
||||
user.delete()
|
||||
return Response(
|
||||
{
|
||||
"error": (
|
||||
f"Failed to send verification email. Please check your SMTP settings "
|
||||
f"in the .env file. The server error was: {e}"
|
||||
)
|
||||
},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"status": "User created, please verify your email",
|
||||
"username": username,
|
||||
"email": email,
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny])
|
||||
def resend_verification(self, request: Request) -> Response:
|
||||
username = request.data.get("username")
|
||||
if not username:
|
||||
return Response({"error": "Username required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
user = User.objects.get(username=username, is_active=False)
|
||||
except User.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "User not found or already active"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
code = _generate_code()
|
||||
user.email_confirmation_code = code
|
||||
user.save()
|
||||
|
||||
verification_link = (
|
||||
f"{settings.FRONTEND_URL}/verify-email?username={user.username}&code={code}"
|
||||
)
|
||||
|
||||
try:
|
||||
send_email_task.delay(
|
||||
subject="Verify your Holy Flavors account",
|
||||
message=(
|
||||
f"Hi {user.username},\n\nYour new verification code is: {code}\n\n"
|
||||
f"Alternatively, you can complete your registration by clicking the link below:\n"
|
||||
f"{verification_link}"
|
||||
),
|
||||
recipient_list=[user.email],
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"error": f"Failed to send email: {e}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return Response({"status": "Verification code resent!"})
|
||||
|
||||
@action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny])
|
||||
def verify_signup(self, request: Request) -> Response:
|
||||
username = request.data.get("username")
|
||||
code = request.data.get("code")
|
||||
try:
|
||||
user = User.objects.get(username=username, is_active=False)
|
||||
except User.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "User not found or already active"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if user.email_confirmation_code == code:
|
||||
user.is_active = True
|
||||
user.email_confirmation_code = None
|
||||
user.save()
|
||||
return Response({"status": "Account verified successfully!"})
|
||||
return Response({"error": "Invalid verification code"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(
|
||||
detail=False,
|
||||
methods=["get"],
|
||||
permission_classes=[permissions.AllowAny],
|
||||
url_path="profile/(?P<username>[^/.]+)",
|
||||
)
|
||||
def public_profile(self, request: Request, username=None) -> Response:
|
||||
try:
|
||||
user = User.objects.get(username=username)
|
||||
except User.DoesNotExist:
|
||||
return Response({"error": "User not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
ratings = user.ratings.select_related("flavor", "flavor__category").order_by("-score")
|
||||
comments = user.profile_comments.select_related("author").order_by("-created_at")
|
||||
|
||||
followers = user.followers.all()
|
||||
following = user.following.all()
|
||||
|
||||
return Response(
|
||||
{
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"theme": user.theme,
|
||||
"avatar": (request.build_absolute_uri(user.avatar.url) if user.avatar else None),
|
||||
"following_count": following.count(),
|
||||
"followers_count": followers.count(),
|
||||
"is_following": (
|
||||
request.user.following.filter(pk=user.pk).exists()
|
||||
if request.user.is_authenticated
|
||||
else False
|
||||
),
|
||||
"ratings": RatingSerializer(ratings, many=True, context={"request": request}).data,
|
||||
"comments": ProfileCommentSerializer(
|
||||
comments, many=True, context={"request": request}
|
||||
).data,
|
||||
"followers": UserSerializer(
|
||||
followers, many=True, context={"request": request}
|
||||
).data,
|
||||
"following": UserSerializer(
|
||||
following, many=True, context={"request": request}
|
||||
).data,
|
||||
}
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated])
|
||||
def add_comment(self, request: Request, pk=None) -> Response:
|
||||
profile_owner = self.get_object()
|
||||
text = request.data.get("text")
|
||||
if not text:
|
||||
return Response({"error": "Text is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
comment = ProfileComment.objects.create(
|
||||
profile_owner=profile_owner,
|
||||
author=request.user,
|
||||
text=text,
|
||||
)
|
||||
|
||||
if profile_owner != request.user:
|
||||
Notification.objects.create(
|
||||
recipient=profile_owner,
|
||||
actor=request.user,
|
||||
notification_type="profile_comment",
|
||||
profile_comment=comment,
|
||||
)
|
||||
|
||||
return Response(
|
||||
ProfileCommentSerializer(comment, context={"request": request}).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["delete"],
|
||||
url_path="delete_comment/(?P<comment_id>[^/.]+)",
|
||||
permission_classes=[permissions.IsAuthenticated],
|
||||
)
|
||||
def delete_comment(self, request: Request, pk=None, comment_id=None) -> Response:
|
||||
profile_owner = self.get_object()
|
||||
try:
|
||||
comment = ProfileComment.objects.get(pk=comment_id, profile_owner=profile_owner)
|
||||
except ProfileComment.DoesNotExist:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if (
|
||||
comment.author != request.user
|
||||
and not request.user.is_superuser
|
||||
and profile_owner != request.user
|
||||
):
|
||||
return Response(status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
comment.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def me(self, request: Request) -> Response:
|
||||
log_user_ip(request.user, request)
|
||||
return Response(self.get_serializer(request.user).data)
|
||||
|
||||
@action(detail=False, methods=["patch"])
|
||||
def update_preferences(self, request: Request) -> Response:
|
||||
user = request.user
|
||||
theme = request.data.get("theme")
|
||||
language = request.data.get("language")
|
||||
banner_id = request.data.get("selected_banner")
|
||||
|
||||
updated = False
|
||||
if theme in dict(User.THEME_CHOICES):
|
||||
user.theme = theme
|
||||
updated = True
|
||||
if language in dict(User.LANGUAGE_CHOICES):
|
||||
user.language = language
|
||||
updated = True
|
||||
|
||||
if banner_id is not None:
|
||||
if banner_id == "":
|
||||
user.selected_banner = None
|
||||
updated = True
|
||||
else:
|
||||
try:
|
||||
banner = Banner.objects.get(pk=banner_id, is_enabled=True)
|
||||
user.selected_banner = banner
|
||||
updated = True
|
||||
except Banner.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Invalid or disabled banner"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if updated:
|
||||
user.save()
|
||||
return Response(
|
||||
{
|
||||
"status": "preferences updated",
|
||||
"theme": user.theme,
|
||||
"language": user.language,
|
||||
"selected_banner": (user.selected_banner.id if user.selected_banner else None),
|
||||
}
|
||||
)
|
||||
return Response({"error": "No valid updates provided"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=False, methods=["post"])
|
||||
def update_avatar(self, request: Request) -> Response:
|
||||
user = request.user
|
||||
avatar = request.FILES.get("avatar")
|
||||
if not avatar:
|
||||
return Response({"error": "No avatar provided"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if avatar.size > 2 * 1024 * 1024:
|
||||
return Response(
|
||||
{"error": "File size exceeds 2MB limit"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
user.avatar = avatar
|
||||
user.save()
|
||||
return Response(self.get_serializer(user).data)
|
||||
|
||||
@action(detail=False, methods=["post"])
|
||||
def change_password(self, request: Request) -> Response:
|
||||
user = request.user
|
||||
old_password = request.data.get("old_password")
|
||||
new_password = request.data.get("new_password")
|
||||
if not user.check_password(old_password):
|
||||
return Response({"error": "Wrong old password"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
return Response({"status": "password changed"})
|
||||
|
||||
@action(detail=False, methods=["patch"])
|
||||
def update_profile(self, request: Request) -> Response:
|
||||
user = request.user
|
||||
username = request.data.get("username")
|
||||
email = request.data.get("email")
|
||||
|
||||
message = ""
|
||||
if username and username != user.username:
|
||||
if User.objects.filter(username=username).exists():
|
||||
return Response(
|
||||
{"error": "Username already taken"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
user.username = username
|
||||
|
||||
if email and email != user.email:
|
||||
code = _generate_code()
|
||||
user.pending_email = email
|
||||
user.email_confirmation_code = code
|
||||
send_email_task.delay(
|
||||
subject="Confirm your new email",
|
||||
message=f"Hi {user.username},\n\nYour confirmation code is: {code}",
|
||||
recipient_list=[email],
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
)
|
||||
message = " Confirmation code sent to new email."
|
||||
|
||||
user.save()
|
||||
data = self.get_serializer(user).data
|
||||
if message:
|
||||
data["message"] = message
|
||||
return Response(data)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated])
|
||||
def follow(self, request: Request, pk=None) -> Response:
|
||||
user_to_follow = self.get_object()
|
||||
if user_to_follow == request.user:
|
||||
return Response(
|
||||
{"error": "You cannot follow yourself"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not request.user.following.filter(pk=user_to_follow.pk).exists():
|
||||
request.user.following.add(user_to_follow)
|
||||
Notification.objects.create(
|
||||
recipient=user_to_follow,
|
||||
actor=request.user,
|
||||
notification_type="follow",
|
||||
)
|
||||
|
||||
return Response({"status": "followed"})
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated])
|
||||
def unfollow(self, request: Request, pk=None) -> Response:
|
||||
user_to_unfollow = self.get_object()
|
||||
request.user.following.remove(user_to_unfollow)
|
||||
return Response({"status": "unfollowed"})
|
||||
|
||||
@action(detail=False, methods=["post"])
|
||||
def confirm_email(self, request: Request) -> Response:
|
||||
user = request.user
|
||||
code = request.data.get("code")
|
||||
if not user.email_confirmation_code or code != user.email_confirmation_code:
|
||||
return Response(
|
||||
{"error": "Invalid or expired code"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
user.email = user.pending_email
|
||||
user.pending_email = None
|
||||
user.email_confirmation_code = None
|
||||
user.save()
|
||||
return Response({"status": "Email confirmed", "email": user.email})
|
||||
|
||||
@action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny])
|
||||
@method_decorator(ratelimit(key="ip", rate="3/h", method="POST", block=True))
|
||||
def request_password_reset(self, request: Request) -> Response:
|
||||
email = request.data.get("email")
|
||||
try:
|
||||
user = User.objects.get(email=email, is_active=True)
|
||||
except User.DoesNotExist:
|
||||
return Response(
|
||||
{"status": "If an account exists with this email, a reset code has been sent."}
|
||||
)
|
||||
|
||||
code = _generate_code()
|
||||
user.email_confirmation_code = code
|
||||
user.save()
|
||||
|
||||
send_email_task.delay(
|
||||
subject="Password Reset Request",
|
||||
message=f"Hi {user.username},\n\nYour password reset code is: {code}",
|
||||
recipient_list=[email],
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
)
|
||||
return Response(
|
||||
{"status": "If an account exists with this email, a reset code has been sent."}
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny])
|
||||
@method_decorator(ratelimit(key="ip", rate="5/h", method="POST", block=True))
|
||||
def complete_password_reset(self, request: Request) -> Response:
|
||||
email = request.data.get("email")
|
||||
code = request.data.get("code")
|
||||
new_password = request.data.get("password")
|
||||
|
||||
try:
|
||||
user = User.objects.get(email=email, email_confirmation_code=code)
|
||||
except User.DoesNotExist:
|
||||
return Response({"error": "Invalid email or code"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user.set_password(new_password)
|
||||
user.email_confirmation_code = None
|
||||
user.save()
|
||||
return Response({"status": "Password reset successful!"})
|
||||
|
||||
@action(detail=False, methods=["post"])
|
||||
@method_decorator(ratelimit(key="user", rate="3/h", method="POST", block=True))
|
||||
def request_account_deletion(self, request: Request) -> Response:
|
||||
code = _generate_code()
|
||||
user = request.user
|
||||
user.email_confirmation_code = code
|
||||
user.save()
|
||||
|
||||
send_email_task.delay(
|
||||
subject="Confirm Account Deletion - Holy Flavors Archive",
|
||||
message=(
|
||||
f"Hi {user.username},\n\nYou requested to delete your account. "
|
||||
f"This action is permanent.\n\nYour deletion code is: {code}"
|
||||
),
|
||||
recipient_list=[user.email],
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
)
|
||||
return Response({"status": "Code sent to your email"})
|
||||
|
||||
@action(detail=False, methods=["post"])
|
||||
def confirm_account_deletion(self, request: Request) -> Response:
|
||||
user = request.user
|
||||
code = request.data.get("code")
|
||||
if not user.email_confirmation_code or code != user.email_confirmation_code:
|
||||
return Response(
|
||||
{"error": "Invalid or expired code"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
user.delete()
|
||||
return Response({"status": "Account deleted"})
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def dashboard(self, request: Request) -> Response:
|
||||
from django.db.models import Avg, Q
|
||||
|
||||
user = request.user
|
||||
rated_ids = user.ratings.values_list("flavor_id", flat=True)
|
||||
followed_users = user.following.all()
|
||||
|
||||
missing_flavors = (
|
||||
Flavor.objects.exclude(id__in=rated_ids)
|
||||
.exclude(category__name="Packs and other")
|
||||
.select_related("category")
|
||||
.annotate(
|
||||
average_rating=Avg("ratings__score"),
|
||||
followed_average_rating=Avg(
|
||||
"ratings__score", filter=Q(ratings__user__in=followed_users)
|
||||
),
|
||||
)
|
||||
.order_by("category__name", "name")
|
||||
)
|
||||
|
||||
rated_flavors = (
|
||||
user.ratings.select_related("flavor", "flavor__category")
|
||||
.prefetch_related("replies", "replies__user")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"user": UserSerializer(user).data,
|
||||
"rated_count": rated_flavors.count(),
|
||||
"missing_count": missing_flavors.count(),
|
||||
"missing_flavors": FlavorSerializer(
|
||||
missing_flavors, many=True, context={"request": request}
|
||||
).data,
|
||||
"my_ratings": RatingSerializer(
|
||||
rated_flavors, many=True, context={"request": request}
|
||||
).data,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class NotificationViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = NotificationSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
pagination_class = None
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.user.notifications.select_related(
|
||||
"actor", "rating__flavor", "reply__rating__flavor"
|
||||
).order_by("-created_at")
|
||||
|
||||
@action(detail=False, methods=["post"])
|
||||
def mark_all_read(self, request: Request) -> Response:
|
||||
request.user.notifications.filter(is_read=False).update(is_read=True)
|
||||
return Response({"status": "all read"})
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def mark_read(self, request: Request, pk=None) -> Response:
|
||||
notification = self.get_object()
|
||||
notification.is_read = True
|
||||
notification.save()
|
||||
return Response({"status": "marked read"})
|
||||
@@ -0,0 +1,3 @@
|
||||
from holy_backend.celery import app as celery_app
|
||||
|
||||
__all__ = ("celery_app",)
|
||||
@@ -11,6 +11,6 @@ import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'holy_backend.settings')
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Celery application for holy_backend.
|
||||
|
||||
Autodiscovers tasks from installed Django apps. Broker/backend configuration
|
||||
is read from Django settings (CELERY_* namespace).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
|
||||
|
||||
app = Celery("holy_backend")
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
app.autodiscover_tasks()
|
||||
@@ -1,231 +0,0 @@
|
||||
"""
|
||||
Django settings for holy_backend project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 6.0.2.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/6.0/ref/settings/
|
||||
"""
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-zos99ue2a89!4jrv4g@_b4nn4z!5o*@8k!5cr9!u$j%_@xb=l8')
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = os.environ.get('DEBUG', 'false').lower() == 'true'
|
||||
|
||||
# Simplification: Use a single DOMAIN variable to derive multiple settings
|
||||
DOMAIN = os.environ.get('DOMAIN', 'localhost')
|
||||
|
||||
# Helper to build lists from env or defaults
|
||||
def get_env_list(var_name, default_list):
|
||||
val = os.environ.get(var_name)
|
||||
if val:
|
||||
return [item.strip() for item in val.split(',')]
|
||||
return default_list
|
||||
|
||||
ALLOWED_HOSTS = get_env_list('ALLOWED_HOSTS', ['localhost', '127.0.0.1', 'backend', 'worker', DOMAIN])
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'whitenoise.runserver_nostatic',
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
# Third party
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
'corsheaders',
|
||||
'django_filters',
|
||||
'drf_spectacular',
|
||||
'django_ratelimit',
|
||||
# Local
|
||||
'api',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'compression_middleware.middleware.CompressionMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'holy_backend.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'holy_backend.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
}
|
||||
}
|
||||
|
||||
# Cache configuration for Ratelimiting
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.db.DatabaseCache',
|
||||
'LOCATION': 'django_ratelimit_cache',
|
||||
}
|
||||
}
|
||||
|
||||
# Silence ratelimit warnings for non-Redis/Memcached backends
|
||||
SILENCED_SYSTEM_CHECKS = ['django_ratelimit.W001', 'django_ratelimit.E003']
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
STATICFILES_STORAGE = 'whitenoise.storage.CompressedStaticFilesStorage'
|
||||
WHITENOISE_INDEX_PAGE = True
|
||||
|
||||
MEDIA_URL = 'media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
# --- Reverse Proxy Configuration ---
|
||||
# These settings are crucial for running behind a proxy like Traefik
|
||||
USE_X_FORWARDED_HOST = True
|
||||
USE_X_FORWARDED_PORT = True
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
|
||||
# Production Security
|
||||
if not DEBUG:
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
SECURE_HSTS_SECONDS = 31536000 # 1 year
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_HSTS_PRELOAD = True
|
||||
|
||||
# Read CSRF trusted origins from environment variable
|
||||
CSRF_TRUSTED_ORIGINS = get_env_list('CSRF_TRUSTED_ORIGINS', [f'https://{DOMAIN}', 'http://localhost:5173'])
|
||||
|
||||
# Custom User Model
|
||||
AUTH_USER_MODEL = 'api.User'
|
||||
|
||||
# REST Framework
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'rest_framework.authentication.TokenAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
],
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
|
||||
],
|
||||
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'],
|
||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||
'PAGE_SIZE': 50,
|
||||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||
}
|
||||
|
||||
# CORS
|
||||
CORS_ALLOWED_ORIGINS = get_env_list('CORS_ALLOWED_ORIGINS', [f'https://{DOMAIN}', 'http://localhost:5173', 'http://127.0.0.1:5173'])
|
||||
|
||||
FRONTEND_URL = os.environ.get('FRONTEND_URL', f'https://{DOMAIN}').rstrip('/')
|
||||
|
||||
import os
|
||||
# ... (near top of file)
|
||||
|
||||
# Email (SMTP)
|
||||
# For production, set these via environment variables.
|
||||
# For development, it will default to printing emails to the console.
|
||||
EMAIL_HOST = os.environ.get('EMAIL_HOST')
|
||||
EMAIL_PORT = int(os.environ.get('EMAIL_PORT', 587))
|
||||
# Default to False unless explicitly set to 'true' in the .env file
|
||||
EMAIL_USE_TLS = os.environ.get('EMAIL_USE_TLS', 'false').lower() == 'true'
|
||||
EMAIL_USE_SSL = os.environ.get('EMAIL_USE_SSL', 'false').lower() == 'true'
|
||||
EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER')
|
||||
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD')
|
||||
EMAIL_SKIP_CERT_VERIFICATION = os.environ.get('EMAIL_SKIP_CERT_VERIFICATION', 'false').lower() == 'true'
|
||||
DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', EMAIL_HOST_USER)
|
||||
SERVER_EMAIL = DEFAULT_FROM_EMAIL
|
||||
|
||||
if EMAIL_HOST:
|
||||
EMAIL_BACKEND = 'api.email_backend.InsecureEmailBackend'
|
||||
else:
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
|
||||
# Swagger
|
||||
SPECTACULAR_SETTINGS = {
|
||||
'TITLE': 'Holy Flavors API',
|
||||
'DESCRIPTION': 'API for rating flavors',
|
||||
'VERSION': '1.0.0',
|
||||
'SERVE_INCLUDE_SCHEMA': False,
|
||||
}
|
||||
Whitespace-only changes.
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
Base Django settings shared across all environments.
|
||||
|
||||
Environment-specific overrides live in `dev.py` and `prod.py`. Select one with
|
||||
the DJANGO_SETTINGS_MODULE environment variable.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
def get_env_list(var_name: str, default_list: list[str]) -> list[str]:
|
||||
val = os.environ.get(var_name)
|
||||
if val:
|
||||
return [item.strip() for item in val.split(",")]
|
||||
return default_list
|
||||
|
||||
|
||||
DOMAIN = os.environ.get("DOMAIN", "localhost")
|
||||
|
||||
ALLOWED_HOSTS = get_env_list(
|
||||
"ALLOWED_HOSTS",
|
||||
["localhost", "127.0.0.1", "backend", "worker", DOMAIN],
|
||||
)
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"whitenoise.runserver_nostatic",
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
# Third party
|
||||
"rest_framework",
|
||||
"rest_framework.authtoken",
|
||||
"rest_framework_simplejwt",
|
||||
"rest_framework_simplejwt.token_blacklist",
|
||||
"corsheaders",
|
||||
"django_filters",
|
||||
"drf_spectacular",
|
||||
"django_ratelimit",
|
||||
"django_celery_beat",
|
||||
# Local
|
||||
"api",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"compression_middleware.middleware.CompressionMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "holy_backend.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "holy_backend.wsgi.application"
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.db.DatabaseCache",
|
||||
"LOCATION": "django_ratelimit_cache",
|
||||
}
|
||||
}
|
||||
|
||||
SILENCED_SYSTEM_CHECKS = ["django_ratelimit.W001", "django_ratelimit.E003"]
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "UTC"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedStaticFilesStorage"
|
||||
WHITENOISE_INDEX_PAGE = True
|
||||
|
||||
MEDIA_URL = "media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
||||
# Reverse proxy
|
||||
USE_X_FORWARDED_HOST = True
|
||||
USE_X_FORWARDED_PORT = True
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = get_env_list(
|
||||
"CSRF_TRUSTED_ORIGINS",
|
||||
[f"https://{DOMAIN}", "http://localhost:5173"],
|
||||
)
|
||||
|
||||
AUTH_USER_MODEL = "api.User"
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||
],
|
||||
"DEFAULT_PERMISSION_CLASSES": [
|
||||
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
|
||||
],
|
||||
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||
"PAGE_SIZE": 50,
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
}
|
||||
|
||||
SIMPLE_JWT = {
|
||||
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=60),
|
||||
"REFRESH_TOKEN_LIFETIME": timedelta(days=14),
|
||||
"ROTATE_REFRESH_TOKENS": True,
|
||||
"BLACKLIST_AFTER_ROTATION": True,
|
||||
"AUTH_HEADER_TYPES": ("Bearer",),
|
||||
}
|
||||
|
||||
CORS_ALLOWED_ORIGINS = get_env_list(
|
||||
"CORS_ALLOWED_ORIGINS",
|
||||
[f"https://{DOMAIN}", "http://localhost:5173", "http://127.0.0.1:5173"],
|
||||
)
|
||||
|
||||
FRONTEND_URL = os.environ.get("FRONTEND_URL", f"https://{DOMAIN}").rstrip("/")
|
||||
|
||||
# Email (SMTP)
|
||||
EMAIL_HOST = os.environ.get("EMAIL_HOST")
|
||||
EMAIL_PORT = int(os.environ.get("EMAIL_PORT", 587))
|
||||
EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "false").lower() == "true"
|
||||
EMAIL_USE_SSL = os.environ.get("EMAIL_USE_SSL", "false").lower() == "true"
|
||||
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER")
|
||||
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD")
|
||||
EMAIL_SKIP_CERT_VERIFICATION = (
|
||||
os.environ.get("EMAIL_SKIP_CERT_VERIFICATION", "false").lower() == "true"
|
||||
)
|
||||
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", EMAIL_HOST_USER)
|
||||
SERVER_EMAIL = DEFAULT_FROM_EMAIL
|
||||
|
||||
if EMAIL_HOST:
|
||||
EMAIL_BACKEND = "api.email_backend.InsecureEmailBackend"
|
||||
else:
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
"TITLE": "Holy Flavors API",
|
||||
"DESCRIPTION": "API for rating flavors",
|
||||
"VERSION": "1.0.0",
|
||||
"SERVE_INCLUDE_SCHEMA": False,
|
||||
}
|
||||
|
||||
# Celery
|
||||
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://redis:6379/0")
|
||||
CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "redis://redis:6379/0")
|
||||
CELERY_TIMEZONE = TIME_ZONE
|
||||
CELERY_TASK_TRACK_STARTED = True
|
||||
CELERY_TASK_TIME_LIMIT = 30 * 60
|
||||
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
|
||||
CELERY_TASK_ALWAYS_EAGER = os.environ.get("CELERY_TASK_ALWAYS_EAGER", "false").lower() == "true"
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Development settings. Selected by default via manage.py / wsgi.py / asgi.py."""
|
||||
|
||||
import os
|
||||
|
||||
from holy_backend.settings.base import * # noqa: F401,F403
|
||||
|
||||
DEBUG = True
|
||||
|
||||
# Dev-only fallback. Override via SECRET_KEY env var if you want consistency
|
||||
# across restarts (sessions invalidate when this changes).
|
||||
SECRET_KEY = os.environ.get(
|
||||
"SECRET_KEY",
|
||||
"django-insecure-dev-only-do-not-use-in-production",
|
||||
)
|
||||
|
||||
# Run Celery tasks synchronously in dev/tests unless a broker is available.
|
||||
CELERY_TASK_ALWAYS_EAGER = os.environ.get("CELERY_TASK_ALWAYS_EAGER", "true").lower() == "true"
|
||||
CELERY_TASK_EAGER_PROPAGATES = True
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Production settings. Set DJANGO_SETTINGS_MODULE=holy_backend.settings.prod."""
|
||||
|
||||
import os
|
||||
|
||||
from holy_backend.settings.base import * # noqa: F401,F403
|
||||
|
||||
DEBUG = False
|
||||
|
||||
# Required in production. Fail loudly if not set.
|
||||
try:
|
||||
SECRET_KEY = os.environ["SECRET_KEY"]
|
||||
except KeyError as exc:
|
||||
raise RuntimeError("SECRET_KEY environment variable must be set in production.") from exc
|
||||
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
SECURE_HSTS_SECONDS = 31536000 # 1 year
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_HSTS_PRELOAD = True
|
||||
@@ -1,34 +1,43 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
|
||||
from rest_framework.authtoken.views import obtain_auth_token
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.views.static import serve
|
||||
from django.views.generic.base import TemplateView
|
||||
from django.http import HttpResponse
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.http import HttpResponse
|
||||
from django.urls import include, path
|
||||
from django.views.generic.base import TemplateView
|
||||
from django.views.static import serve
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView
|
||||
|
||||
|
||||
def health_check(request):
|
||||
return HttpResponse("OK", content_type="text/plain")
|
||||
|
||||
|
||||
# Clean the admin path
|
||||
admin_url = os.environ.get('ADMIN_URL', 'admin').strip('/')
|
||||
admin_url = os.environ.get("ADMIN_URL", "admin").strip("/")
|
||||
|
||||
urlpatterns = [
|
||||
# Health check
|
||||
path('health/', health_check, name='health_check'),
|
||||
|
||||
path("health/", health_check, name="health_check"),
|
||||
# Robots.txt
|
||||
path('robots.txt', TemplateView.as_view(template_name="robots.txt", content_type="text/plain")),
|
||||
|
||||
path("robots.txt", TemplateView.as_view(template_name="robots.txt", content_type="text/plain")),
|
||||
# Ensure admin ends with a slash for Django's redirection logic
|
||||
path(f'{admin_url}/', admin.site.urls),
|
||||
path('api/', include('api.urls')),
|
||||
path('api/token/', obtain_auth_token, name='api_token'),
|
||||
path('api-auth/', include('rest_framework.urls')),
|
||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||
path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
||||
path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
|
||||
path('media/<path:path>', serve, {'document_root': settings.MEDIA_ROOT}),
|
||||
path(f"{admin_url}/", admin.site.urls),
|
||||
path("api/", include("api.urls")),
|
||||
path("api/auth/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
|
||||
path("api/auth/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
|
||||
path("api/auth/token/verify/", TokenVerifyView.as_view(), name="token_verify"),
|
||||
# Legacy alias: old DRF TokenAuth endpoint now returns a JWT pair so clients
|
||||
# relying on POST /api/token/ keep working for one release.
|
||||
path("api/token/", TokenObtainPairView.as_view(), name="api_token_legacy"),
|
||||
path("api-auth/", include("rest_framework.urls")),
|
||||
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
|
||||
path(
|
||||
"api/schema/swagger-ui/",
|
||||
SpectacularSwaggerView.as_view(url_name="schema"),
|
||||
name="swagger-ui",
|
||||
),
|
||||
path("api/schema/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
|
||||
path("media/<path:path>", serve, {"document_root": settings.MEDIA_ROOT}),
|
||||
]
|
||||
@@ -11,6 +11,6 @@ import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'holy_backend.settings')
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
|
||||
|
||||
application = get_wsgi_application()
|
||||
+3
-2
@@ -1,12 +1,13 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'holy_backend.settings')
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
@@ -18,5 +19,5 @@ def main():
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,49 @@
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
extend-exclude = ["migrations", "venv", ".venv"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"B", # flake8-bugbear
|
||||
"UP", # pyupgrade
|
||||
"C4", # flake8-comprehensions
|
||||
"DJ", # flake8-django
|
||||
]
|
||||
ignore = [
|
||||
"E501", # line too long (formatter handles it)
|
||||
"B008", # function call in default argument (common in DRF)
|
||||
"DJ001", # null=True on string fields (pre-existing schema decisions)
|
||||
"DJ008", # missing __str__ (not all models need one)
|
||||
"DJ012", # model attribute ordering style
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"api/migrations/*.py" = ["ALL"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"]
|
||||
exclude = ["migrations/", "venv/", ".venv/"]
|
||||
disallow_untyped_defs = false
|
||||
check_untyped_defs = true
|
||||
warn_unused_ignores = true
|
||||
warn_redundant_casts = true
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.django-stubs]
|
||||
django_settings_module = "holy_backend.settings.dev"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = "holy_backend.settings.dev"
|
||||
python_files = ["test_*.py", "tests.py"]
|
||||
testpaths = ["api/tests"]
|
||||
addopts = "-ra --strict-markers"
|
||||
@@ -0,0 +1,14 @@
|
||||
-r requirements.txt
|
||||
|
||||
# Linting / formatting
|
||||
ruff>=0.6.0
|
||||
|
||||
# Type checking
|
||||
mypy>=1.11.0
|
||||
django-stubs[compatible-mypy]>=5.0.0
|
||||
djangorestframework-stubs[compatible-mypy]>=3.15.0
|
||||
|
||||
# Testing
|
||||
pytest>=8.0.0
|
||||
pytest-django>=4.8.0
|
||||
pytest-cov>=5.0.0
|
||||
@@ -10,3 +10,7 @@ gunicorn>=21.2.0
|
||||
django-ratelimit>=4.1.0
|
||||
django-compression-middleware>=0.5.0
|
||||
requests>=2.31.0
|
||||
celery[redis]>=5.4.0
|
||||
django-celery-beat>=2.6.0
|
||||
redis>=5.0.0
|
||||
djangorestframework-simplejwt>=5.3.1
|
||||
+54
-1
@@ -1,4 +1,19 @@
|
||||
services:
|
||||
# --- Redis (Celery broker + result backend) ---
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: holy-redis
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- holy-net
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
# --- Backend Service ---
|
||||
backend:
|
||||
build:
|
||||
@@ -17,6 +32,12 @@ services:
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- DOMAIN=${DOMAIN:-localhost}
|
||||
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-holy_backend.settings.prod}
|
||||
- CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://redis:6379/0}
|
||||
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health/"]
|
||||
interval: 10s
|
||||
@@ -28,18 +49,49 @@ services:
|
||||
build:
|
||||
context: ./backend
|
||||
container_name: holy-worker
|
||||
command: python manage.py worker
|
||||
command: celery -A holy_backend worker -l info
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- holy-net
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- DOMAIN=${DOMAIN:-localhost}
|
||||
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-holy_backend.settings.prod}
|
||||
- CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://redis:6379/0}
|
||||
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./legacy:/app/legacy
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
beat:
|
||||
build:
|
||||
context: ./backend
|
||||
container_name: holy-beat
|
||||
command: celery -A holy_backend beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- holy-net
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- DOMAIN=${DOMAIN:-localhost}
|
||||
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-holy_backend.settings.prod}
|
||||
- CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://redis:6379/0}
|
||||
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./legacy:/app/legacy
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -71,3 +123,4 @@ networks:
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
redis_data:
|
||||
@@ -0,0 +1,5 @@
|
||||
dist
|
||||
node_modules
|
||||
public
|
||||
*.lock
|
||||
package-lock.json
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
@@ -19,5 +19,18 @@ export default defineConfig([
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-empty-object-type': 'warn',
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
'react-hooks/immutability': 'warn',
|
||||
'react-hooks/purity': 'warn',
|
||||
'react-refresh/only-export-components': 'warn',
|
||||
'no-empty': 'warn',
|
||||
},
|
||||
},
|
||||
])
|
||||
Generated
+1240
-4
File diff suppressed because it is too large.
Load diff
+14
-1
@@ -7,6 +7,11 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,js,json,css}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,json,css}\"",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -26,17 +31,25 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/colorthief": "^2.6.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/ui": "^4.1.4",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^29.0.2",
|
||||
"prettier": "^3.8.2",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
+233
-359
@@ -1,8 +1,6 @@
|
||||
import React, { useState, useEffect, useMemo, Suspense, lazy } from 'react';
|
||||
import { Routes, Route, Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import React, { Suspense, lazy, useEffect, useState } from 'react';
|
||||
import { Routes, Route, Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ThemeProvider,
|
||||
CssBaseline,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Typography,
|
||||
@@ -19,31 +17,31 @@ import {
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
Collapse,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Autocomplete,
|
||||
CircularProgress,
|
||||
ListItemAvatar,
|
||||
ListSubheader,
|
||||
Badge,
|
||||
alpha,
|
||||
useMediaQuery
|
||||
useMediaQuery,
|
||||
useTheme as useMuiTheme,
|
||||
} from '@mui/material';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
||||
import ExpandLess from '@mui/icons-material/ExpandLess';
|
||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||
import { getTheme, type CatppuccinTheme } from './theme';
|
||||
import api from './api';
|
||||
import './i18n';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import api from './lib/api';
|
||||
import Footer from './components/Footer';
|
||||
import { formatDate } from './utils/date';
|
||||
import CookieBanner from './components/CookieBanner';
|
||||
import { formatDate } from './utils/date';
|
||||
import { GlobalSearch } from './app/GlobalSearch';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import { useNotifications } from './hooks/useNotifications';
|
||||
import type { Notification } from './contexts/NotificationContext';
|
||||
import type { CatppuccinTheme } from './theme';
|
||||
|
||||
// Code splitting
|
||||
const MainPage = lazy(() => import('./pages/MainPage'));
|
||||
const CategoryFlavors = lazy(() => import('./pages/categories/CategoryFlavors'));
|
||||
const FlavorDetail = lazy(() => import('./pages/flavors/FlavorDetail'));
|
||||
@@ -61,296 +59,72 @@ const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
|
||||
const AdminRatingDetail = lazy(() => import('./pages/AdminRatingDetail'));
|
||||
const AdminReplyDetail = lazy(() => import('./pages/AdminReplyDetail'));
|
||||
|
||||
interface SearchResult {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'flavor' | 'category' | 'user';
|
||||
subtitle: string;
|
||||
image_url: string | null;
|
||||
slug: string | null;
|
||||
}
|
||||
|
||||
interface Notification {
|
||||
id: number;
|
||||
actor_username: string;
|
||||
actor_avatar: string | null;
|
||||
notification_type: 'reply' | 'mention' | 'follow' | 'ticket_new' | 'ticket_reply' | 'profile_comment';
|
||||
rating: number | null;
|
||||
reply: number | null;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
flavor_name: string | null;
|
||||
flavor_id: number | null;
|
||||
profile_owner_username: string | null;
|
||||
}
|
||||
|
||||
const GlobalSearch = () => {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState('');
|
||||
const [options, setOptions] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const isMobile = useMediaQuery('(max-width:1150px)');
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get(`flavors/search/?q=${encodeURIComponent(query)}`);
|
||||
const results = Array.isArray(res.data) ? res.data : (res.data.results || []);
|
||||
setOptions(results);
|
||||
} catch (err) {
|
||||
console.error('Search failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const q = params.get('q');
|
||||
if (q) setQuery(q);
|
||||
}, [location.search]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (query.trim()) {
|
||||
navigate(`/?q=${encodeURIComponent(query.trim())}`);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1, mx: isMobile ? 1 : 4 }}>
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
freeSolo
|
||||
size="small"
|
||||
loading={loading}
|
||||
options={options}
|
||||
filterOptions={(x) => x}
|
||||
getOptionLabel={(option) => {
|
||||
if (typeof option === 'string') return option;
|
||||
return option.name || '';
|
||||
}}
|
||||
inputValue={query}
|
||||
onInputChange={(_, newValue) => setQuery(typeof newValue === 'string' ? newValue : '')}
|
||||
onChange={(_, newValue) => {
|
||||
if (newValue && typeof newValue !== 'string') {
|
||||
if (newValue.type === 'flavor') navigate(`/flavor/${newValue.id}`);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch(e as any);
|
||||
}
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
fullWidth
|
||||
placeholder={t('common.search')}
|
||||
sx={{
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 2,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
color: 'inherit',
|
||||
'& fieldset': { border: 'none' },
|
||||
'&:hover fieldset': { border: 'none' },
|
||||
'&.Mui-focused fieldset': { border: 'none' },
|
||||
outline: 'none'
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
outline: 'none'
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon sx={{ color: 'inherit', opacity: 0.7 }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<React.Fragment>
|
||||
{loading ? <CircularProgress color="inherit" size={20} /> : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</React.Fragment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
renderOption={(props, option) => {
|
||||
const { key, ...optionProps } = props as any;
|
||||
return (
|
||||
<Box component="li" key={key} {...optionProps} sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={option.image_url || undefined}
|
||||
sx={{ width: 32, height: 32, borderRadius: 1, objectFit: 'contain', bgcolor: 'action.hover', p: 0.5 }}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{option.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{option.subtitle}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { i18n, t } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [themeName, setThemeName] = useState<CatppuccinTheme>((localStorage.getItem('theme') as CatppuccinTheme) || 'holy_light');
|
||||
const muiTheme = useMuiTheme();
|
||||
const { user, following, loadingUser, logout } = useAuth();
|
||||
const { themeName, handleThemeChange } = useTheme();
|
||||
const { notifications, markAllRead, markRead } = useNotifications();
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [notifAnchorEl, setNotifAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [catAnchorEl, setCatAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [categories, setCategories] = useState<{name: string, slug: string}[]>([]);
|
||||
const [user, setUser] = useState<{username: string, avatar: string | null, unread_notifications_count: number, is_superuser: boolean} | null>(null);
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [following, setFollowing] = useState<{id: number, username: string, avatar: string | null}[]>([]);
|
||||
const [loadingUser, setLoadingUser] = useState(true);
|
||||
const [categories, setCategories] = useState<{ name: string; slug: string }[]>([]);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [catOpen, setCatOpen] = useState(false);
|
||||
const [adminMode, setAdminMode] = useState(localStorage.getItem('admin-mode') === 'true');
|
||||
const isMobile = useMediaQuery('(max-width:1150px)');
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get('categories/')
|
||||
.then((res) => {
|
||||
const data = Array.isArray(res.data) ? res.data : res.data.results || [];
|
||||
setCategories(data);
|
||||
})
|
||||
.catch(() => setCategories([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.backgroundColor = muiTheme.palette.background.default;
|
||||
document.body.style.color = muiTheme.palette.text.primary;
|
||||
}, [muiTheme]);
|
||||
|
||||
const toggleAdminMode = () => {
|
||||
const newVal = !adminMode;
|
||||
setAdminMode(newVal);
|
||||
localStorage.setItem('admin-mode', String(newVal));
|
||||
};
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const res = await api.get('notifications/');
|
||||
const data = Array.isArray(res.data) ? res.data : (res.data.results || []);
|
||||
setNotifications(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch notifications');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const [userRes, catRes] = await Promise.all([
|
||||
token ? api.get('users/me/') : Promise.resolve({ data: null }),
|
||||
api.get('categories/')
|
||||
]);
|
||||
|
||||
if (userRes.data && userRes.data.username) {
|
||||
setUser(userRes.data);
|
||||
if (userRes.data.theme) {
|
||||
setThemeName(userRes.data.theme);
|
||||
localStorage.setItem('theme', userRes.data.theme);
|
||||
}
|
||||
if (userRes.data.language) i18n.changeLanguage(userRes.data.language);
|
||||
|
||||
try {
|
||||
const followRes = await api.get('users/following_list/');
|
||||
const followData = Array.isArray(followRes.data) ? followRes.data : (followRes.data.results || []);
|
||||
setFollowing(followData);
|
||||
fetchNotifications();
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch following list');
|
||||
}
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
|
||||
const catData = Array.isArray(catRes.data) ? catRes.data : (catRes.data.results || []);
|
||||
setCategories(catData);
|
||||
} catch (err) {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoadingUser(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (localStorage.getItem('token')) {
|
||||
fetchNotifications();
|
||||
api.get('users/me/').then(res => setUser(res.data)).catch(() => {});
|
||||
}
|
||||
}, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const theme = useMemo(() => getTheme(themeName), [themeName]);
|
||||
|
||||
useEffect(() => {
|
||||
// Update body styles to match theme immediately (overriding index.html blocking script)
|
||||
document.body.style.backgroundColor = theme.palette.background.default;
|
||||
document.body.style.color = theme.palette.text.primary;
|
||||
}, [theme]);
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
await api.post('notifications/mark_all_read/');
|
||||
setNotifications(notifications.map(n => ({ ...n, is_read: true })));
|
||||
if (user) setUser({ ...user, unread_notifications_count: 0 });
|
||||
} catch (err) {
|
||||
console.error('Failed to mark all as read');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotificationClick = async (notif: Notification) => {
|
||||
if (!notif.is_read) {
|
||||
try {
|
||||
await api.post(`notifications/${notif.id}/mark_read/`);
|
||||
setNotifications(notifications.map(n => n.id === notif.id ? { ...n, is_read: true } : n));
|
||||
if (user) setUser({ ...user, unread_notifications_count: Math.max(0, user.unread_notifications_count - 1) });
|
||||
} catch (err) { /* ignore */ }
|
||||
}
|
||||
setNotifAnchorEl(null);
|
||||
if (notif.flavor_id) {
|
||||
window.location.href = `/flavor/${notif.flavor_id}`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleThemeChange = async (newTheme: CatppuccinTheme) => {
|
||||
setThemeName(newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
if (user) {
|
||||
try {
|
||||
await api.patch('users/update_preferences/', { theme: newTheme });
|
||||
} catch (err) {
|
||||
console.error('Failed to update theme on server');
|
||||
}
|
||||
}
|
||||
const onThemeChange = async (name: CatppuccinTheme) => {
|
||||
await handleThemeChange(name, !!user);
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setUser(null);
|
||||
setFollowing([]);
|
||||
i18n.changeLanguage(navigator.language.split('-')[0] || 'en');
|
||||
window.location.href = '/';
|
||||
const handleNotificationClick = async (notif: Notification) => {
|
||||
if (!notif.is_read) await markRead(notif.id);
|
||||
setNotifAnchorEl(null);
|
||||
if (notif.notification_type.startsWith('ticket')) {
|
||||
navigate('/support');
|
||||
} else if (notif.notification_type === 'profile_comment') {
|
||||
navigate(`/profile/${user?.username}`);
|
||||
} else if (notif.notification_type === 'follow') {
|
||||
navigate(`/profile/${notif.actor_username}`);
|
||||
} else if (notif.flavor_id) {
|
||||
window.location.href = `/flavor/${notif.flavor_id}`;
|
||||
}
|
||||
};
|
||||
|
||||
const drawer = (
|
||||
<Box sx={{ width: 280 }} role="presentation">
|
||||
<Box sx={{ p: 2, display: 'flex', alignItems: 'center' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
<Link to="/" onClick={() => setDrawerOpen(false)} style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
style={{ color: 'inherit', textDecoration: 'none' }}
|
||||
>
|
||||
Holy Flavors Archive
|
||||
</Link>
|
||||
</Typography>
|
||||
@@ -377,7 +151,7 @@ const App: React.FC = () => {
|
||||
</ListItemButton>
|
||||
<Collapse in={catOpen} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
{categories.map(cat => (
|
||||
{categories.map((cat) => (
|
||||
<ListItemButton
|
||||
key={cat.slug}
|
||||
component={Link}
|
||||
@@ -396,7 +170,11 @@ const App: React.FC = () => {
|
||||
{user ? (
|
||||
<>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton component={Link} to={`/profile/${user.username}`} onClick={() => setDrawerOpen(false)}>
|
||||
<ListItemButton
|
||||
component={Link}
|
||||
to={`/profile/${user.username}`}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
>
|
||||
<ListItemText
|
||||
primary={t('nav.profile')}
|
||||
secondary={
|
||||
@@ -433,7 +211,7 @@ const App: React.FC = () => {
|
||||
<ListItemButton onClick={toggleAdminMode}>
|
||||
<ListItemText
|
||||
primary={t('admin.adminMode')}
|
||||
secondary={adminMode ? "ON" : "OFF"}
|
||||
secondary={adminMode ? 'ON' : 'OFF'}
|
||||
sx={{ color: adminMode ? 'primary.main' : 'inherit' }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
@@ -442,8 +220,15 @@ const App: React.FC = () => {
|
||||
|
||||
{user.is_superuser && (
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton component={Link} to="/admin-panel" onClick={() => setDrawerOpen(false)}>
|
||||
<ListItemText primary="Admin Panel" sx={{ color: 'primary.main', fontWeight: 'bold' }} />
|
||||
<ListItemButton
|
||||
component={Link}
|
||||
to="/admin-panel"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
>
|
||||
<ListItemText
|
||||
primary="Admin Panel"
|
||||
sx={{ color: 'primary.main', fontWeight: 'bold' }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
)}
|
||||
@@ -451,16 +236,27 @@ const App: React.FC = () => {
|
||||
{following.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<ListSubheader sx={{ bgcolor: 'transparent', fontWeight: 'bold', lineHeight: '32px' }}>{t('nav.following')}</ListSubheader>
|
||||
{following.map(f => (
|
||||
<ListSubheader
|
||||
sx={{ bgcolor: 'transparent', fontWeight: 'bold', lineHeight: '32px' }}
|
||||
>
|
||||
{t('nav.following')}
|
||||
</ListSubheader>
|
||||
{following.map((f) => (
|
||||
<ListItem key={f.id} disablePadding>
|
||||
<ListItemButton component={Link} to={`/profile/${f.username}`} onClick={() => setDrawerOpen(false)}>
|
||||
<ListItemButton
|
||||
component={Link}
|
||||
to={`/profile/${f.username}`}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar src={f.avatar || undefined} sx={{ width: 32, height: 32 }}>
|
||||
{!f.avatar && f.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={f.username} primaryTypographyProps={{ variant: 'body2' }} />
|
||||
<ListItemText
|
||||
primary={f.username}
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
@@ -468,7 +264,7 @@ const App: React.FC = () => {
|
||||
)}
|
||||
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton onClick={handleLogout}>
|
||||
<ListItemButton onClick={logout}>
|
||||
<ListItemText primary={t('nav.logout')} sx={{ color: 'error.main' }} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
@@ -499,47 +295,46 @@ const App: React.FC = () => {
|
||||
sx={{ mt: 1 }}
|
||||
PaperProps={{ sx: { width: 320, maxHeight: 400, borderRadius: 1 } }}
|
||||
>
|
||||
<Box sx={{ p: 1.5, display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>Notifications</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||
Notifications
|
||||
</Typography>
|
||||
{(user?.unread_notifications_count || 0) > 0 && (
|
||||
<Button size="small" onClick={handleMarkAllRead}>Mark all read</Button>
|
||||
<Button size="small" onClick={markAllRead}>
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
{notifications.length === 0 ? (
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">No notifications yet</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No notifications yet
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
notifications.map(n => {
|
||||
const handleClick = () => {
|
||||
if (n.notification_type.startsWith('ticket')) {
|
||||
handleNotificationClick(n);
|
||||
navigate('/support');
|
||||
} else if (n.notification_type === 'profile_comment') {
|
||||
handleNotificationClick(n);
|
||||
navigate(`/profile/${user?.username}`);
|
||||
} else if (n.notification_type === 'follow') {
|
||||
handleNotificationClick(n);
|
||||
navigate(`/profile/${n.actor_username}`);
|
||||
} else {
|
||||
handleNotificationClick(n);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
notifications.map((n) => (
|
||||
<MenuItem
|
||||
key={n.id}
|
||||
onClick={handleClick}
|
||||
onClick={() => handleNotificationClick(n)}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
px: 2,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: n.is_read ? 'transparent' : alpha(theme.palette.primary.main, 0.05),
|
||||
bgcolor: n.is_read ? 'transparent' : alpha(muiTheme.palette.primary.main, 0.05),
|
||||
whiteSpace: 'normal',
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
alignItems: 'flex-start'
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Avatar src={n.actor_avatar || undefined} sx={{ width: 32, height: 32 }}>
|
||||
@@ -547,47 +342,70 @@ const App: React.FC = () => {
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.4 }}>
|
||||
<strong>{n.actor_username}</strong> {
|
||||
n.notification_type === 'reply' ? `replied to your review on ${n.flavor_name}` :
|
||||
n.notification_type === 'mention' ? `mentioned you on ${n.flavor_name}` :
|
||||
n.notification_type === 'follow' ? t('community.notifFollow') :
|
||||
n.notification_type === 'profile_comment' ? `left a message on your guestbook` :
|
||||
n.notification_type === 'ticket_new' ? t('community.notifTicketNew') :
|
||||
user?.is_superuser ? t('community.notifTicketReplyAdmin') : t('community.notifTicketReply')
|
||||
}
|
||||
<strong>{n.actor_username}</strong>{' '}
|
||||
{n.notification_type === 'reply'
|
||||
? `replied to your review on ${n.flavor_name}`
|
||||
: n.notification_type === 'mention'
|
||||
? `mentioned you on ${n.flavor_name}`
|
||||
: n.notification_type === 'follow'
|
||||
? t('community.notifFollow')
|
||||
: n.notification_type === 'profile_comment'
|
||||
? `left a message on your guestbook`
|
||||
: n.notification_type === 'ticket_new'
|
||||
? t('community.notifTicketNew')
|
||||
: user?.is_superuser
|
||||
? t('community.notifTicketReplyAdmin')
|
||||
: t('community.notifTicketReply')}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDate(n.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
);
|
||||
})
|
||||
))
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', width: '100%', overflowX: 'hidden' }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
width: '100%',
|
||||
overflowX: 'hidden',
|
||||
}}
|
||||
>
|
||||
<AppBar
|
||||
position="sticky"
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.8),
|
||||
bgcolor: (th) => alpha(th.palette.background.paper, 0.8),
|
||||
backdropFilter: 'blur(12px)',
|
||||
color: 'text.primary'
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
<Toolbar sx={{ minHeight: { xs: 56, sm: 64 }, px: { xs: 1, sm: 4, md: 6 } }}>
|
||||
<Typography variant="h6" component="div" sx={{ fontWeight: 'bold', mr: { xs: 0, sm: 2 }, display: { xs: 'none', sm: 'block' } }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="div"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
mr: { xs: 0, sm: 2 },
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
}}
|
||||
>
|
||||
<Link to="/" style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>Holy Flavors Archive</Box>
|
||||
<Box component="span" sx={{ display: { xs: 'inline', sm: 'none' } }}>HFA</Box>
|
||||
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>
|
||||
Holy Flavors Archive
|
||||
</Box>
|
||||
<Box component="span" sx={{ display: { xs: 'inline', sm: 'none' } }}>
|
||||
HFA
|
||||
</Box>
|
||||
</Link>
|
||||
</Typography>
|
||||
|
||||
@@ -597,7 +415,12 @@ const App: React.FC = () => {
|
||||
color="inherit"
|
||||
component={Link}
|
||||
to="/community"
|
||||
sx={{ fontWeight: 'bold', textTransform: 'none', fontSize: '1rem', mr: 2 }}
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
textTransform: 'none',
|
||||
fontSize: '1rem',
|
||||
mr: 2,
|
||||
}}
|
||||
>
|
||||
{t('nav.community')}
|
||||
</Button>
|
||||
@@ -617,7 +440,7 @@ const App: React.FC = () => {
|
||||
elevation={3}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
{categories.map(cat => (
|
||||
{categories.map((cat) => (
|
||||
<MenuItem
|
||||
key={cat.slug}
|
||||
component={Link}
|
||||
@@ -640,7 +463,15 @@ const App: React.FC = () => {
|
||||
{user ? (
|
||||
<Box sx={{ display: isMobile ? 'none' : 'flex', ml: 1 }}>
|
||||
<IconButton color="inherit" onClick={(e) => setAnchorEl(e.currentTarget)}>
|
||||
<Avatar src={user.avatar || undefined} sx={{ width: 36, height: 36, border: '2px solid', borderColor: 'primary.main' }}>
|
||||
<Avatar
|
||||
src={user.avatar || undefined}
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
border: '2px solid',
|
||||
borderColor: 'primary.main',
|
||||
}}
|
||||
>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</IconButton>
|
||||
@@ -651,34 +482,63 @@ const App: React.FC = () => {
|
||||
elevation={3}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
<MenuItem component={Link} to={`/profile/${user.username}`} onClick={() => setAnchorEl(null)}>{t('nav.profile')}</MenuItem>
|
||||
<MenuItem component={Link} to="/dashboard" onClick={() => setAnchorEl(null)}>{t('nav.dashboard')}</MenuItem>
|
||||
<MenuItem component={Link} to="/settings" onClick={() => setAnchorEl(null)}>{t('nav.settings')}</MenuItem>
|
||||
<MenuItem component={Link} to="/support" onClick={() => setAnchorEl(null)}>{t('support.title')}</MenuItem>
|
||||
<MenuItem
|
||||
component={Link}
|
||||
to={`/profile/${user.username}`}
|
||||
onClick={() => setAnchorEl(null)}
|
||||
>
|
||||
{t('nav.profile')}
|
||||
</MenuItem>
|
||||
<MenuItem component={Link} to="/dashboard" onClick={() => setAnchorEl(null)}>
|
||||
{t('nav.dashboard')}
|
||||
</MenuItem>
|
||||
<MenuItem component={Link} to="/settings" onClick={() => setAnchorEl(null)}>
|
||||
{t('nav.settings')}
|
||||
</MenuItem>
|
||||
<MenuItem component={Link} to="/support" onClick={() => setAnchorEl(null)}>
|
||||
{t('support.title')}
|
||||
</MenuItem>
|
||||
{user.is_superuser && (
|
||||
<MenuItem onClick={toggleAdminMode} sx={{ color: adminMode ? 'primary.main' : 'inherit' }}>
|
||||
{t('admin.adminMode')}: {adminMode ? "ON" : "OFF"}
|
||||
<MenuItem
|
||||
onClick={toggleAdminMode}
|
||||
sx={{ color: adminMode ? 'primary.main' : 'inherit' }}
|
||||
>
|
||||
{t('admin.adminMode')}: {adminMode ? 'ON' : 'OFF'}
|
||||
</MenuItem>
|
||||
)}
|
||||
{user.is_superuser && (
|
||||
<MenuItem component={Link} to="/admin-panel" onClick={() => setAnchorEl(null)} sx={{ fontWeight: 'bold', color: 'primary.main' }}>Admin Panel</MenuItem>
|
||||
)} <Divider />
|
||||
<MenuItem onClick={handleLogout} sx={{ color: 'error.main' }}>{t('nav.logout')}</MenuItem> </Menu>
|
||||
<MenuItem
|
||||
component={Link}
|
||||
to="/admin-panel"
|
||||
onClick={() => setAnchorEl(null)}
|
||||
sx={{ fontWeight: 'bold', color: 'primary.main' }}
|
||||
>
|
||||
Admin Panel
|
||||
</MenuItem>
|
||||
)}
|
||||
<Divider />
|
||||
<MenuItem onClick={logout} sx={{ color: 'error.main' }}>
|
||||
{t('nav.logout')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: isMobile ? 'none' : 'block' }}>
|
||||
<Button variant="contained" component={Link} to="/login" sx={{ borderRadius: 2 }}>Login</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
component={Link}
|
||||
to="/login"
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isMobile && (
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<IconButton color="inherit" onClick={() => setDrawerOpen(true)} sx={{ ml: 1 }}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
@@ -686,16 +546,18 @@ const App: React.FC = () => {
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
>
|
||||
<Drawer anchor="right" open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
|
||||
<Box sx={{ flexGrow: 1, width: '100%' }}>
|
||||
<Suspense fallback={<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<MainPage adminMode={adminMode} />} />
|
||||
<Route path="/category/:slug" element={<CategoryFlavors />} />
|
||||
@@ -705,7 +567,10 @@ const App: React.FC = () => {
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/privacy" element={<Privacy />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/settings" element={<Settings themeName={themeName} onThemeChange={handleThemeChange} />} />
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<Settings themeName={themeName} onThemeChange={onThemeChange} />}
|
||||
/>
|
||||
<Route path="/support" element={<Support />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/admin-panel" element={<AdminDashboard />} />
|
||||
@@ -713,14 +578,23 @@ const App: React.FC = () => {
|
||||
<Route path="/admin-panel/rating/:id" element={<AdminRatingDetail />} />
|
||||
<Route path="/admin-panel/reply/:id" element={<AdminReplyDetail />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="*" element={<Box sx={{ p: 4, textAlign: 'center' }}><Typography variant="h5">404 - Page Not Found</Typography><Button component={Link} to="/" sx={{ mt: 2 }}>Back to Home</Button></Box>} />
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h5">404 - Page Not Found</Typography>
|
||||
<Button component={Link} to="/" sx={{ mt: 2 }}>
|
||||
Back to Home
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Box>
|
||||
<Footer />
|
||||
<CookieBanner onThemeChange={(name) => onThemeChange(name)} currentTheme={themeName} />
|
||||
</Box>
|
||||
<CookieBanner onThemeChange={handleThemeChange} currentTheme={themeName} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import axios from 'axios';
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
// Does this cookie string begin with the name we want?
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api/',
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
// Set DRF Auth Token if available from localStorage
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Token ${token}`;
|
||||
}
|
||||
|
||||
// Set CSRF token for state-changing methods
|
||||
const csrfToken = getCookie('csrftoken');
|
||||
const method = config.method?.toUpperCase();
|
||||
if (csrfToken && method && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
||||
if (config.headers) {
|
||||
config.headers['X-CSRFToken'] = csrfToken;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
CircularProgress,
|
||||
InputAdornment,
|
||||
TextField,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGlobalSearch } from '../hooks/useGlobalSearch';
|
||||
|
||||
export const GlobalSearch = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useMediaQuery('(max-width:1150px)');
|
||||
const { query, setQuery, options, loading } = useGlobalSearch();
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (query.trim()) {
|
||||
navigate(`/?q=${encodeURIComponent(query.trim())}`);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1, mx: isMobile ? 1 : 4 }}>
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
freeSolo
|
||||
size="small"
|
||||
loading={loading}
|
||||
options={options}
|
||||
filterOptions={(x) => x}
|
||||
getOptionLabel={(option) => (typeof option === 'string' ? option : option.name || '')}
|
||||
inputValue={query}
|
||||
onInputChange={(_, newValue) => setQuery(typeof newValue === 'string' ? newValue : '')}
|
||||
onChange={(_, newValue) => {
|
||||
if (newValue && typeof newValue !== 'string' && newValue.type === 'flavor') {
|
||||
navigate(`/flavor/${newValue.id}`);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSearch(e as unknown as React.FormEvent);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
fullWidth
|
||||
placeholder={t('common.search')}
|
||||
sx={{
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 2,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
color: 'inherit',
|
||||
'& fieldset': { border: 'none' },
|
||||
'&:hover fieldset': { border: 'none' },
|
||||
'&.Mui-focused fieldset': { border: 'none' },
|
||||
outline: 'none',
|
||||
},
|
||||
'& .MuiInputBase-input': { outline: 'none' },
|
||||
}}
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon sx={{ color: 'inherit', opacity: 0.7 }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<React.Fragment>
|
||||
{loading ? <CircularProgress color="inherit" size={20} /> : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</React.Fragment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
renderOption={(props, option) => {
|
||||
const { key, ...optionProps } = props as React.HTMLAttributes<HTMLLIElement> & {
|
||||
key: React.Key;
|
||||
};
|
||||
return (
|
||||
<Box
|
||||
component="li"
|
||||
key={key}
|
||||
{...optionProps}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={option.image_url || undefined}
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 1,
|
||||
objectFit: 'contain',
|
||||
bgcolor: 'action.hover',
|
||||
p: 0.5,
|
||||
}}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
{option.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{option.subtitle}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { ThemeProvider } from '../contexts/ThemeContext';
|
||||
import { AuthProvider } from '../contexts/AuthContext';
|
||||
import { NotificationProvider } from '../contexts/NotificationContext';
|
||||
import '../i18n';
|
||||
|
||||
export const AppProviders = ({ children }: { children: ReactNode }) => (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<NotificationProvider>{children}</NotificationProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
@@ -1,12 +1,22 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button,
|
||||
TextField, Box, Typography, Alert, Slider, Stack,
|
||||
Tooltip, IconButton
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Box,
|
||||
Typography,
|
||||
Alert,
|
||||
Slider,
|
||||
Stack,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import api from '../api';
|
||||
import api from '../lib/api';
|
||||
|
||||
interface BannerSettingSchema {
|
||||
key: string;
|
||||
@@ -35,7 +45,12 @@ interface BannerSettingsDialogProps {
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClose, banner, onSave }) => {
|
||||
const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
banner,
|
||||
onSave,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [localSettings, setLocalSettings] = useState<any>({});
|
||||
const [error, setError] = useState('');
|
||||
@@ -66,7 +81,7 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClo
|
||||
const updateSetting = (key: string, value: any) => {
|
||||
setLocalSettings((prev: any) => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -128,30 +143,28 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClo
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 'bold' }}>
|
||||
Banner Settings: {banner?.name}
|
||||
</DialogTitle>
|
||||
<DialogTitle sx={{ fontWeight: 'bold' }}>Banner Settings: {banner?.name}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{banner?.schema && banner.schema.length > 0 ? (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{banner.schema.map(field => renderField(field))}
|
||||
</Box>
|
||||
<Box sx={{ mt: 1 }}>{banner.schema.map((field) => renderField(field))}</Box>
|
||||
) : (
|
||||
<Typography color="text.secondary" sx={{ fontStyle: 'italic', py: 2 }}>
|
||||
No dynamic schema defined for this banner. Please use the JSON editor or check the banner configuration files.
|
||||
No dynamic schema defined for this banner. Please use the JSON editor or check the
|
||||
banner configuration files.
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button onClick={onClose} color="inherit">{t('common.cancel')}</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="contained"
|
||||
disabled={saving}
|
||||
sx={{ px: 4 }}
|
||||
>
|
||||
<Button onClick={onClose} color="inherit">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} variant="contained" disabled={saving} sx={{ px: 4 }}>
|
||||
{saving ? t('common.loading') : t('common.save')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogActions,
|
||||
Box
|
||||
Box,
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CatppuccinTheme } from '../theme';
|
||||
@@ -53,7 +53,7 @@ const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme
|
||||
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.98),
|
||||
backdropFilter: 'blur(10px)',
|
||||
boxShadow: (theme) => `0 20px 50px ${alpha(theme.palette.primary.main, 0.2)}`,
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ fontWeight: 'bold', fontSize: '1.5rem', pt: 3 }}>
|
||||
@@ -63,21 +63,28 @@ const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme
|
||||
<Typography variant="body1" sx={{ mb: 2, lineHeight: 1.7 }}>
|
||||
{t('cookieBanner.description')}
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'action.hover',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
mt: 2
|
||||
}}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold', display: 'block', mb: 1, textTransform: 'uppercase' }}>
|
||||
mt: 2,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontWeight: 'bold', display: 'block', mb: 1, textTransform: 'uppercase' }}
|
||||
>
|
||||
Technical Details:
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
• Session ID: Required for login & authentication.<br />
|
||||
• CSRF Token: Protects your account from malicious attacks.<br />
|
||||
• Preference Store: Remembers your theme and language choices.
|
||||
• Session ID: Required for login & authentication.
|
||||
<br />
|
||||
• CSRF Token: Protects your account from malicious attacks.
|
||||
<br />• Preference Store: Remembers your theme and language choices.
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
@@ -89,14 +96,22 @@ const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme
|
||||
onClick={handleToggleTheme}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', m: '0 !important' }}
|
||||
>
|
||||
{currentTheme === 'holy_light' ? t('cookieBanner.switchToDark') : t('cookieBanner.switchToLight')}
|
||||
{currentTheme === 'holy_light'
|
||||
? t('cookieBanner.switchToDark')
|
||||
: t('cookieBanner.switchToLight')}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleAccept}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', px: 4, m: '0 !important' }}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
px: 4,
|
||||
m: '0 !important',
|
||||
}}
|
||||
>
|
||||
{t('cookieBanner.accept')}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
import api from '../lib/api';
|
||||
import GenerativeBanner from './GenerativeBanner';
|
||||
import NebulaBanner from './NebulaBanner';
|
||||
import FirefliesBanner from './FirefliesBanner';
|
||||
@@ -27,17 +27,34 @@ interface DynamicBannerProps {
|
||||
const MatrixBanner: React.FC<DynamicBannerProps & { settings: any }> = ({ username, settings }) => {
|
||||
// Placeholder for a second model
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, width: '100%', height: '100%',
|
||||
background: 'rgba(0,0,0,0.8)', color: '#0f0', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace',
|
||||
zIndex: 1, overflow: 'hidden', opacity: settings.opacity || 0.5
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'rgba(0,0,0,0.8)',
|
||||
color: '#0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: 'monospace',
|
||||
zIndex: 1,
|
||||
overflow: 'hidden',
|
||||
opacity: settings.opacity || 0.5,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<div key={i} style={{ animation: `matrixFall ${2 + Math.random() * 3}s linear infinite` }}>
|
||||
<div
|
||||
key={i}
|
||||
style={{ animation: `matrixFall ${2 + Math.random() * 3}s linear infinite` }}
|
||||
>
|
||||
{username.split('').map((char, j) => (
|
||||
<span key={j} style={{ display: 'block' }}>{char}</span>
|
||||
<span key={j} style={{ display: 'block' }}>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
@@ -62,7 +79,7 @@ const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
|
||||
const fetchActiveBanner = async () => {
|
||||
try {
|
||||
const res = await api.get('banners/active/', {
|
||||
params: { username: props.username }
|
||||
params: { username: props.username },
|
||||
});
|
||||
setBanner(res.data);
|
||||
} catch (err) {
|
||||
|
||||
@@ -38,7 +38,10 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
|
||||
const seed = useMemo(() => getSeed(username), [username]);
|
||||
|
||||
const brightColors = useMemo(() => {
|
||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const colors =
|
||||
palette.length >= 2
|
||||
? palette
|
||||
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const getBrightness = (hex: string) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
||||
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
||||
@@ -89,7 +92,7 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
|
||||
color: brightColors[i % brightColors.length],
|
||||
phase: pseudoRandom(i + 2500) * Math.PI * 2,
|
||||
noiseOffset: pseudoRandom(i + 3000) * 100,
|
||||
isEvading: false
|
||||
isEvading: false,
|
||||
});
|
||||
}
|
||||
return newSwarm;
|
||||
@@ -113,7 +116,7 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
|
||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||
resize();
|
||||
|
||||
let mouse = { x: -1000, y: -1000 };
|
||||
const mouse = { x: -1000, y: -1000 };
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = e.clientX - rect.left;
|
||||
@@ -123,7 +126,10 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; });
|
||||
parent.addEventListener('mouseleave', () => {
|
||||
mouse.x = -1000;
|
||||
mouse.y = -1000;
|
||||
});
|
||||
}
|
||||
|
||||
let time = 0;
|
||||
@@ -139,7 +145,14 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
|
||||
|
||||
// Vibrant theme-aware night sky background
|
||||
const isLight = theme.palette.mode === 'light';
|
||||
const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width);
|
||||
const bgGrad = ctx.createRadialGradient(
|
||||
width / 2,
|
||||
height / 2,
|
||||
0,
|
||||
width / 2,
|
||||
height / 2,
|
||||
width,
|
||||
);
|
||||
if (isLight) {
|
||||
// For light themes, we still need a "dusk/night" feel for fireflies to make sense.
|
||||
// We use a deeper, more saturated version of the theme's colors.
|
||||
@@ -236,7 +249,17 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [seed, brightColors, count, baseSpeed, evasionRadius, evasionStrength, glowSizeMultiplier, flickerSpeed, theme]);
|
||||
}, [
|
||||
seed,
|
||||
brightColors,
|
||||
count,
|
||||
baseSpeed,
|
||||
evasionRadius,
|
||||
evasionStrength,
|
||||
glowSizeMultiplier,
|
||||
flickerSpeed,
|
||||
theme,
|
||||
]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
@@ -248,7 +271,7 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -8,18 +8,55 @@ const Footer: React.FC = () => {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<Box component="footer" sx={{ bgcolor: 'background.paper', pt: 6, pb: 4, mt: 'auto', borderTop: '1px solid', borderColor: 'divider' }}>
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
pt: 6,
|
||||
pb: 4,
|
||||
mt: 'auto',
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, justifyContent: 'space-between', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}>
|
||||
© {year} Holy Flavors Archive
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||
<MuiLink component={Link} to="/about" color="inherit" sx={{ fontSize: '0.8rem', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}>
|
||||
<MuiLink
|
||||
component={Link}
|
||||
to="/about"
|
||||
color="inherit"
|
||||
sx={{
|
||||
fontSize: '0.8rem',
|
||||
textDecoration: 'none',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
Impressum
|
||||
</MuiLink>
|
||||
<MuiLink component={Link} to="/privacy" color="inherit" sx={{ fontSize: '0.8rem', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}>
|
||||
<MuiLink
|
||||
component={Link}
|
||||
to="/privacy"
|
||||
color="inherit"
|
||||
sx={{
|
||||
fontSize: '0.8rem',
|
||||
textDecoration: 'none',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
Privacy Policy
|
||||
</MuiLink>
|
||||
</Box>
|
||||
|
||||
@@ -14,7 +14,13 @@ interface GenerativeBannerProps {
|
||||
};
|
||||
}
|
||||
|
||||
const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette, ratingsCount, followersCount, settings }) => {
|
||||
const GenerativeBanner: React.FC<GenerativeBannerProps> = ({
|
||||
username,
|
||||
palette,
|
||||
ratingsCount,
|
||||
followersCount,
|
||||
settings,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -43,7 +49,10 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
|
||||
|
||||
// Configuration - High density for "complex web" feel
|
||||
const nodeCount = Math.min(nodeCountBase + Math.floor(ratingsCount * 1.5), 120);
|
||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const colors =
|
||||
palette.length >= 2
|
||||
? palette
|
||||
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
|
||||
interface Node {
|
||||
x: number;
|
||||
@@ -69,11 +78,14 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
|
||||
const bx = pseudoRandom(i) * width;
|
||||
const by = pseudoRandom(i + 100) * height;
|
||||
newNodes.push({
|
||||
x: bx, y: by,
|
||||
baseX: bx, baseY: by,
|
||||
vx: 0, vy: 0,
|
||||
x: bx,
|
||||
y: by,
|
||||
baseX: bx,
|
||||
baseY: by,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
color: colors[i % colors.length],
|
||||
size: 1 + pseudoRandom(i + 200) * 3
|
||||
size: 1 + pseudoRandom(i + 200) * 3,
|
||||
});
|
||||
}
|
||||
return newNodes;
|
||||
@@ -97,7 +109,7 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
|
||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||
resize();
|
||||
|
||||
let mouse = { x: -1000, y: -1000, down: false };
|
||||
const mouse = { x: -1000, y: -1000, down: false };
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
@@ -113,8 +125,12 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = () => { mouse.down = true; };
|
||||
const handleMouseUp = () => { mouse.down = false; };
|
||||
const handleMouseDown = () => {
|
||||
mouse.down = true;
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
mouse.down = false;
|
||||
};
|
||||
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
@@ -122,9 +138,16 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
|
||||
parent.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
parent.addEventListener('mousedown', handleMouseDown);
|
||||
parent.addEventListener('mouseup', handleMouseUp);
|
||||
parent.addEventListener('touchstart', (e) => { mouse.down = true; handleTouchMove(e); });
|
||||
parent.addEventListener('touchstart', (e) => {
|
||||
mouse.down = true;
|
||||
handleTouchMove(e);
|
||||
});
|
||||
parent.addEventListener('touchend', handleMouseUp);
|
||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; });
|
||||
parent.addEventListener('mouseleave', () => {
|
||||
mouse.x = -1000;
|
||||
mouse.y = -1000;
|
||||
mouse.down = false;
|
||||
});
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
@@ -197,7 +220,12 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
|
||||
ctx.lineTo(nodes[j].x, nodes[j].y);
|
||||
|
||||
// Dynamic gradient for the line
|
||||
const lineGrad = ctx.createLinearGradient(nodes[i].x, nodes[i].y, nodes[j].x, nodes[j].y);
|
||||
const lineGrad = ctx.createLinearGradient(
|
||||
nodes[i].x,
|
||||
nodes[i].y,
|
||||
nodes[j].x,
|
||||
nodes[j].y,
|
||||
);
|
||||
lineGrad.addColorStop(0, alpha(nodes[i].color, opacity));
|
||||
lineGrad.addColorStop(1, alpha(nodes[j].color, opacity));
|
||||
|
||||
@@ -226,7 +254,7 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
|
||||
}
|
||||
|
||||
// Draw Nodes
|
||||
nodes.forEach(node => {
|
||||
nodes.forEach((node) => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, node.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = node.color;
|
||||
@@ -256,7 +284,17 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [username, palette, ratingsCount, followersCount, theme, nodeCountBase, connectionDist, speed, globalOpacity]);
|
||||
}, [
|
||||
username,
|
||||
palette,
|
||||
ratingsCount,
|
||||
followersCount,
|
||||
theme,
|
||||
nodeCountBase,
|
||||
connectionDist,
|
||||
speed,
|
||||
globalOpacity,
|
||||
]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
@@ -269,7 +307,7 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
mixBlendMode: theme.palette.mode === 'light' ? 'multiply' : 'screen'
|
||||
mixBlendMode: theme.palette.mode === 'light' ? 'multiply' : 'screen',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -36,7 +36,10 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
const seed = useMemo(() => getSeed(username), [username]);
|
||||
|
||||
const brightColors = useMemo(() => {
|
||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const colors =
|
||||
palette.length >= 2
|
||||
? palette
|
||||
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const getBrightness = (hex: string) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
||||
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
||||
@@ -83,13 +86,16 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
const rx = pseudoRandom(i) * width;
|
||||
const ry = pseudoRandom(i + 500) * height;
|
||||
newBlobs.push({
|
||||
x: rx, y: ry,
|
||||
ox: rx, oy: ry,
|
||||
vx: 0, vy: 0,
|
||||
size: (width / 4) + pseudoRandom(i + 1000) * (width / 3),
|
||||
x: rx,
|
||||
y: ry,
|
||||
ox: rx,
|
||||
oy: ry,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
size: width / 4 + pseudoRandom(i + 1000) * (width / 3),
|
||||
color: brightColors[i % brightColors.length],
|
||||
phase: pseudoRandom(i + 1500) * Math.PI * 2,
|
||||
speedFactor: 0.5 + pseudoRandom(i + 2000)
|
||||
speedFactor: 0.5 + pseudoRandom(i + 2000),
|
||||
});
|
||||
}
|
||||
return newBlobs;
|
||||
@@ -113,7 +119,7 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||
resize();
|
||||
|
||||
let mouse = { x: -1000, y: -1000 };
|
||||
const mouse = { x: -1000, y: -1000 };
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = e.clientX - rect.left;
|
||||
@@ -123,7 +129,10 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; });
|
||||
parent.addEventListener('mouseleave', () => {
|
||||
mouse.x = -1000;
|
||||
mouse.y = -1000;
|
||||
});
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
@@ -138,11 +147,24 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
|
||||
// Background Base
|
||||
const isLight = theme.palette.mode === 'light';
|
||||
const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width);
|
||||
const bgGrad = ctx.createRadialGradient(
|
||||
width / 2,
|
||||
height / 2,
|
||||
0,
|
||||
width / 2,
|
||||
height / 2,
|
||||
width,
|
||||
);
|
||||
if (isLight) {
|
||||
// For light themes, use a deeper, more saturated version of the theme color
|
||||
bgGrad.addColorStop(0, alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.6));
|
||||
bgGrad.addColorStop(1, alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.9));
|
||||
bgGrad.addColorStop(
|
||||
0,
|
||||
alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.6),
|
||||
);
|
||||
bgGrad.addColorStop(
|
||||
1,
|
||||
alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.9),
|
||||
);
|
||||
} else {
|
||||
bgGrad.addColorStop(0, '#0a0a1a');
|
||||
bgGrad.addColorStop(1, '#020205');
|
||||
@@ -214,7 +236,7 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -36,7 +36,10 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
const seed = useMemo(() => getSeed(username), [username]);
|
||||
|
||||
const brightColors = useMemo(() => {
|
||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const colors =
|
||||
palette.length >= 2
|
||||
? palette
|
||||
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const getBrightness = (hex: string) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
||||
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
||||
@@ -64,7 +67,7 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
|
||||
// Mana Stream Logic
|
||||
interface Stream {
|
||||
segments: { x: number, y: number }[];
|
||||
segments: { x: number; y: number }[];
|
||||
color: string;
|
||||
life: number;
|
||||
speed: number;
|
||||
@@ -74,14 +77,14 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
|
||||
const createStream = (width: number, height: number) => {
|
||||
const color = brightColors[Math.floor(pseudoRandom(time) * brightColors.length)];
|
||||
let x = pseudoRandom(time + 1) * width;
|
||||
let y = pseudoRandom(time + 2) * height;
|
||||
const x = pseudoRandom(time + 1) * width;
|
||||
const y = pseudoRandom(time + 2) * height;
|
||||
|
||||
return {
|
||||
segments: [{ x, y }],
|
||||
color,
|
||||
life: 1.0,
|
||||
speed: 2 + pseudoRandom(time + 3) * 4
|
||||
speed: 2 + pseudoRandom(time + 3) * 4,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -114,21 +117,29 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||
resize();
|
||||
|
||||
let mouse = { x: -1000, y: -1000, down: false };
|
||||
const mouse = { x: -1000, y: -1000, down: false };
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = e.clientX - rect.left;
|
||||
mouse.y = e.clientY - rect.top;
|
||||
};
|
||||
const handleMouseDown = () => { mouse.down = true; };
|
||||
const handleMouseUp = () => { mouse.down = false; };
|
||||
const handleMouseDown = () => {
|
||||
mouse.down = true;
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
mouse.down = false;
|
||||
};
|
||||
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('mousedown', handleMouseDown);
|
||||
parent.addEventListener('mouseup', handleMouseUp);
|
||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; });
|
||||
parent.addEventListener('mouseleave', () => {
|
||||
mouse.x = -1000;
|
||||
mouse.y = -1000;
|
||||
mouse.down = false;
|
||||
});
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
@@ -143,7 +154,14 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
|
||||
// Background
|
||||
const isLight = theme.palette.mode === 'light';
|
||||
const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width);
|
||||
const bgGrad = ctx.createRadialGradient(
|
||||
width / 2,
|
||||
height / 2,
|
||||
0,
|
||||
width / 2,
|
||||
height / 2,
|
||||
width,
|
||||
);
|
||||
if (isLight) {
|
||||
// For light themes, use a deeper, more saturated version of the theme color
|
||||
bgGrad.addColorStop(0, alpha(brightColors[2] || brightColors[0], 0.65));
|
||||
@@ -164,7 +182,7 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
|
||||
for (let y = -gridSize; y < height + gridSize; y += h / 2) {
|
||||
for (let x = -gridSize; x < width + gridSize; x += w * 0.75) {
|
||||
const xOffset = (Math.floor(y / (h/2)) % 2 === 0) ? 0 : w * 0.375;
|
||||
const xOffset = Math.floor(y / (h / 2)) % 2 === 0 ? 0 : w * 0.375;
|
||||
const finalX = x + xOffset;
|
||||
const finalY = y;
|
||||
|
||||
@@ -172,8 +190,10 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
const dist = Math.sqrt(Math.pow(finalX - mouse.x, 2) + Math.pow(finalY - mouse.y, 2));
|
||||
const mouseImpact = Math.max(0, (200 - dist) / 200) * (mouse.down ? 2 : 1);
|
||||
|
||||
const dx = Math.sin(time + (finalX * 0.01)) * gridSize * distortionFactor * (1 + mouseImpact);
|
||||
const dy = Math.cos(time + (finalY * 0.01)) * gridSize * distortionFactor * (1 + mouseImpact);
|
||||
const dx =
|
||||
Math.sin(time + finalX * 0.01) * gridSize * distortionFactor * (1 + mouseImpact);
|
||||
const dy =
|
||||
Math.cos(time + finalY * 0.01) * gridSize * distortionFactor * (1 + mouseImpact);
|
||||
|
||||
const drawX = finalX + dx;
|
||||
const drawY = finalY + dy;
|
||||
@@ -190,7 +210,7 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
if (pseudoRandom(Math.floor(finalX) + Math.floor(finalY)) > 0.92) {
|
||||
ctx.fillStyle = alpha(brightColors[1], opacity * 0.5);
|
||||
ctx.font = `${gridSize * 0.5}px serif`;
|
||||
ctx.fillText("⚡", drawX - 5, drawY + 5);
|
||||
ctx.fillText('⚡', drawX - 5, drawY + 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,7 +252,7 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
});
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
|
||||
streams = streams.filter(s => s.life > 0);
|
||||
streams = streams.filter((s) => s.life > 0);
|
||||
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
};
|
||||
@@ -248,7 +268,16 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [seed, brightColors, gridSize, pulseSpeed, glowIntensity, streamCount, distortionFactor, theme]);
|
||||
}, [
|
||||
seed,
|
||||
brightColors,
|
||||
gridSize,
|
||||
pulseSpeed,
|
||||
glowIntensity,
|
||||
streamCount,
|
||||
distortionFactor,
|
||||
theme,
|
||||
]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
@@ -260,7 +289,7 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,11 @@ interface HextechCorruptionBannerProps {
|
||||
};
|
||||
}
|
||||
|
||||
const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ username, palette, settings }) => {
|
||||
const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({
|
||||
username,
|
||||
palette,
|
||||
settings,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -36,7 +40,10 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
|
||||
const seed = useMemo(() => getSeed(username), [username]);
|
||||
|
||||
const brightColors = useMemo(() => {
|
||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const colors =
|
||||
palette.length >= 2
|
||||
? palette
|
||||
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const getBrightness = (hex: string) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
||||
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
||||
@@ -81,11 +88,14 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
|
||||
const rx = pseudoRandom(i) * width;
|
||||
const ry = pseudoRandom(i + 500) * height;
|
||||
newPoints.push({
|
||||
x: rx, y: ry,
|
||||
ox: rx, oy: ry,
|
||||
vx: 0, vy: 0,
|
||||
x: rx,
|
||||
y: ry,
|
||||
ox: rx,
|
||||
oy: ry,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
phase: pseudoRandom(i + 1000) * Math.PI * 2,
|
||||
color: brightColors[i % brightColors.length]
|
||||
color: brightColors[i % brightColors.length],
|
||||
});
|
||||
}
|
||||
return newPoints;
|
||||
@@ -109,21 +119,29 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
|
||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||
resize();
|
||||
|
||||
let mouse = { x: -1000, y: -1000, down: false };
|
||||
const mouse = { x: -1000, y: -1000, down: false };
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = e.clientX - rect.left;
|
||||
mouse.y = e.clientY - rect.top;
|
||||
};
|
||||
const handleMouseDown = () => { mouse.down = true; };
|
||||
const handleMouseUp = () => { mouse.down = false; };
|
||||
const handleMouseDown = () => {
|
||||
mouse.down = true;
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
mouse.down = false;
|
||||
};
|
||||
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('mousedown', handleMouseDown);
|
||||
parent.addEventListener('mouseup', handleMouseUp);
|
||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; });
|
||||
parent.addEventListener('mouseleave', () => {
|
||||
mouse.x = -1000;
|
||||
mouse.y = -1000;
|
||||
mouse.down = false;
|
||||
});
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
@@ -153,7 +171,7 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
|
||||
p.y = p.oy + driftY + (mouse.down ? -dyMouse * mouseForce * 0.5 : 0);
|
||||
|
||||
const pulse = 0.7 + Math.sin(time * 3 + p.phase) * 0.3;
|
||||
const glowSize = 60 + (mouseForce * 40);
|
||||
const glowSize = 60 + mouseForce * 40;
|
||||
|
||||
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowSize);
|
||||
grad.addColorStop(0, alpha(p.color, 0.8 * glowIntensity * pulse));
|
||||
@@ -183,14 +201,17 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
|
||||
offCtx.scale(dpr, dpr);
|
||||
|
||||
// Draw full dark shell
|
||||
const shellColor = theme.palette.mode === 'light' ? alpha('#1a0a2a', shellDarkness) : alpha('#05020a', shellDarkness);
|
||||
const shellColor =
|
||||
theme.palette.mode === 'light'
|
||||
? alpha('#1a0a2a', shellDarkness)
|
||||
: alpha('#05020a', shellDarkness);
|
||||
offCtx.fillStyle = shellColor;
|
||||
offCtx.fillRect(0, 0, width, height);
|
||||
|
||||
// Cut holes
|
||||
offCtx.globalCompositeOperation = 'destination-out';
|
||||
points.forEach(p => {
|
||||
const holeSize = 15 + Math.sin(time + p.phase) * 5 + (instability * 10);
|
||||
points.forEach((p) => {
|
||||
const holeSize = 15 + Math.sin(time + p.phase) * 5 + instability * 10;
|
||||
|
||||
// Main hole
|
||||
offCtx.beginPath();
|
||||
@@ -199,7 +220,7 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
|
||||
|
||||
// Add secondary smaller holes for that "punctured" look
|
||||
for (let j = 0; j < 3; j++) {
|
||||
const angle = p.phase + (j * Math.PI * 0.6);
|
||||
const angle = p.phase + j * Math.PI * 0.6;
|
||||
const hx = p.x + Math.cos(angle) * (holeSize * 1.5);
|
||||
const hy = p.y + Math.sin(angle) * (holeSize * 1.5);
|
||||
offCtx.beginPath();
|
||||
@@ -214,7 +235,7 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
|
||||
// 3. Add Iridescent Edge Highlights
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
ctx.lineWidth = 2;
|
||||
points.forEach(p => {
|
||||
points.forEach((p) => {
|
||||
const pulse = 0.5 + Math.sin(time * 2 + p.phase) * 0.5;
|
||||
ctx.strokeStyle = alpha(p.color, pulse * 0.4);
|
||||
ctx.beginPath();
|
||||
@@ -237,7 +258,16 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [seed, brightColors, cellCount, shimmerSpeed, glowIntensity, instability, shellDarkness, theme]);
|
||||
}, [
|
||||
seed,
|
||||
brightColors,
|
||||
cellCount,
|
||||
shimmerSpeed,
|
||||
glowIntensity,
|
||||
instability,
|
||||
shellDarkness,
|
||||
theme,
|
||||
]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
@@ -249,7 +279,7 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
Box,
|
||||
alpha
|
||||
alpha,
|
||||
} from '@mui/material';
|
||||
import api from '../api';
|
||||
import api from '../lib/api';
|
||||
|
||||
interface UserSuggestion {
|
||||
id: number;
|
||||
@@ -38,7 +38,7 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
|
||||
multiline = false,
|
||||
rows,
|
||||
size = 'small',
|
||||
onKeyDown
|
||||
onKeyDown,
|
||||
}) => {
|
||||
const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
@@ -58,7 +58,7 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
|
||||
.map((item: any) => ({
|
||||
id: item.id,
|
||||
username: item.name,
|
||||
avatar: item.image_url
|
||||
avatar: item.image_url,
|
||||
}));
|
||||
setAllUsers(users);
|
||||
} catch (err) {
|
||||
@@ -80,8 +80,8 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
|
||||
const query = textBeforeCursor.substring(lastAtPos + 1);
|
||||
// Ensure there's no space or newline between @ and the cursor
|
||||
if (!query.includes(' ') && !query.includes('\n')) {
|
||||
const filtered = allUsers.filter(u =>
|
||||
u.username.toLowerCase().startsWith(query.toLowerCase())
|
||||
const filtered = allUsers.filter((u) =>
|
||||
u.username.toLowerCase().startsWith(query.toLowerCase()),
|
||||
);
|
||||
setSuggestions(filtered);
|
||||
setShowSuggestions(filtered.length > 0);
|
||||
@@ -99,10 +99,7 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
|
||||
const textAfterCursor = value.substring(selectionStart);
|
||||
const lastAtPos = textBeforeCursor.lastIndexOf('@');
|
||||
|
||||
const newValue =
|
||||
value.substring(0, lastAtPos) +
|
||||
'@' + user.username + ' ' +
|
||||
textAfterCursor;
|
||||
const newValue = value.substring(0, lastAtPos) + '@' + user.username + ' ' + textAfterCursor;
|
||||
|
||||
onChange(newValue);
|
||||
setShowSuggestions(false);
|
||||
@@ -163,7 +160,16 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
|
||||
placement="bottom-start"
|
||||
style={{ zIndex: 2000, width: 250 }}
|
||||
>
|
||||
<Paper elevation={8} sx={{ mt: 1, borderRadius: 2, overflow: 'hidden', border: '1px solid', borderColor: 'divider' }}>
|
||||
<Paper
|
||||
elevation={8}
|
||||
sx={{
|
||||
mt: 1,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<MenuList dense sx={{ py: 0 }}>
|
||||
{suggestions.map((user, index) => (
|
||||
<MenuItem
|
||||
@@ -174,12 +180,15 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
|
||||
py: 1,
|
||||
'&.Mui-selected': {
|
||||
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.15),
|
||||
'&:hover': { bgcolor: (theme) => alpha(theme.palette.primary.main, 0.25) }
|
||||
}
|
||||
'&:hover': { bgcolor: (theme) => alpha(theme.palette.primary.main, 0.25) },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemAvatar sx={{ minWidth: 40 }}>
|
||||
<Avatar src={user.avatar || undefined} sx={{ width: 28, height: 28, fontSize: '0.8rem' }}>
|
||||
<Avatar
|
||||
src={user.avatar || undefined}
|
||||
sx={{ width: 28, height: 28, fontSize: '0.8rem' }}
|
||||
>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
|
||||
@@ -17,7 +17,13 @@ interface NebulaBannerProps {
|
||||
};
|
||||
}
|
||||
|
||||
const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsCount, followersCount, settings }) => {
|
||||
const NebulaBanner: React.FC<NebulaBannerProps> = ({
|
||||
username,
|
||||
palette,
|
||||
ratingsCount,
|
||||
followersCount,
|
||||
settings,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -47,7 +53,10 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
|
||||
if (!ctx) return;
|
||||
|
||||
let animationFrameId: number;
|
||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const colors =
|
||||
palette.length >= 2
|
||||
? palette
|
||||
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
@@ -86,7 +95,7 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
|
||||
x: pseudoRandom(i + 5000) * width,
|
||||
y: pseudoRandom(i + 6000) * height,
|
||||
size: pseudoRandom(i + 7000) * 1.5,
|
||||
opacity: 0.1 + pseudoRandom(i + 8000) * 0.5
|
||||
opacity: 0.1 + pseudoRandom(i + 8000) * 0.5,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -95,7 +104,7 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
|
||||
const clusterCount = colors.length * 3; // More clusters
|
||||
const groupCenters = Array.from({ length: clusterCount }).map((_, i) => ({
|
||||
x: (pseudoRandom(i * 15) * 0.8 + 0.1) * width,
|
||||
y: (pseudoRandom(i * 25 + 7) * 0.8 + 0.1) * height
|
||||
y: (pseudoRandom(i * 25 + 7) * 0.8 + 0.1) * height,
|
||||
}));
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
@@ -108,14 +117,17 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
|
||||
const by = center.y + Math.sin(angle) * dist;
|
||||
|
||||
newParticles.push({
|
||||
x: bx, y: by,
|
||||
baseX: bx, baseY: by,
|
||||
vx: 0, vy: 0,
|
||||
x: bx,
|
||||
y: by,
|
||||
baseX: bx,
|
||||
baseY: by,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
size: 40 + pseudoRandom(i + 300) * 80, // Larger, softer blobs
|
||||
color: colors[colorIdx],
|
||||
alpha: 0,
|
||||
targetAlpha: 0.05 + pseudoRandom(i + 400) * 0.15,
|
||||
phase: pseudoRandom(i + 500) * Math.PI * 2
|
||||
phase: pseudoRandom(i + 500) * Math.PI * 2,
|
||||
});
|
||||
}
|
||||
return { stars: newStars, particles: newParticles };
|
||||
@@ -141,22 +153,31 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
|
||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||
resize();
|
||||
|
||||
let mouse = { x: -1000, y: -1000, px: -1000, py: -1000, down: false };
|
||||
const mouse = { x: -1000, y: -1000, px: -1000, py: -1000, down: false };
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.px = mouse.x; mouse.py = mouse.y;
|
||||
mouse.px = mouse.x;
|
||||
mouse.py = mouse.y;
|
||||
mouse.x = e.clientX - rect.left;
|
||||
mouse.y = e.clientY - rect.top;
|
||||
};
|
||||
const handleMouseDown = () => { mouse.down = true; };
|
||||
const handleMouseUp = () => { mouse.down = false; };
|
||||
const handleMouseDown = () => {
|
||||
mouse.down = true;
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
mouse.down = false;
|
||||
};
|
||||
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('mousedown', handleMouseDown);
|
||||
parent.addEventListener('mouseup', handleMouseUp);
|
||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; });
|
||||
parent.addEventListener('mouseleave', () => {
|
||||
mouse.x = -1000;
|
||||
mouse.y = -1000;
|
||||
mouse.down = false;
|
||||
});
|
||||
}
|
||||
|
||||
let time = 0;
|
||||
@@ -172,7 +193,14 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
|
||||
|
||||
// 1. Dynamic Celestial Background
|
||||
const isLight = theme.palette.mode === 'light';
|
||||
const skyGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width);
|
||||
const skyGrad = ctx.createRadialGradient(
|
||||
width / 2,
|
||||
height / 2,
|
||||
0,
|
||||
width / 2,
|
||||
height / 2,
|
||||
width,
|
||||
);
|
||||
|
||||
if (isLight) {
|
||||
// Vibrant Deep Primary for light mode - but dark enough for glowing gas to pop
|
||||
@@ -191,7 +219,7 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
|
||||
|
||||
// 2. Draw Distant Stars
|
||||
ctx.fillStyle = '#fff';
|
||||
stars.forEach(s => {
|
||||
stars.forEach((s) => {
|
||||
ctx.globalAlpha = s.opacity * (0.7 + Math.sin(time * 2 + s.x) * 0.3);
|
||||
ctx.beginPath();
|
||||
ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
|
||||
@@ -273,7 +301,20 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [seed, palette, ratingsCount, followersCount, theme, particleCount, speed, globalOpacity, smearRadius, turbulenceStrength, returnSpeed, starDensity]);
|
||||
}, [
|
||||
seed,
|
||||
palette,
|
||||
ratingsCount,
|
||||
followersCount,
|
||||
theme,
|
||||
particleCount,
|
||||
speed,
|
||||
globalOpacity,
|
||||
smearRadius,
|
||||
turbulenceStrength,
|
||||
returnSpeed,
|
||||
starDensity,
|
||||
]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
@@ -285,7 +326,7 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -13,25 +13,28 @@ const RatingBadge: React.FC<RatingBadgeProps> = ({ score, size = 'medium', sx })
|
||||
const isSmall = size === 'small';
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 0.5,
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
px: isLarge ? 2 : (isSmall ? 1 : 1.5),
|
||||
py: isLarge ? 0.5 : (isSmall ? 0.2 : 0.4),
|
||||
px: isLarge ? 2 : isSmall ? 1 : 1.5,
|
||||
py: isLarge ? 0.5 : isSmall ? 0.2 : 0.4,
|
||||
borderRadius: 2,
|
||||
minWidth: 'fit-content',
|
||||
height: 'fit-content',
|
||||
boxShadow: theme.palette.mode === 'dark'
|
||||
boxShadow:
|
||||
theme.palette.mode === 'dark'
|
||||
? '0 4px 12px rgba(0,0,0,0.4)'
|
||||
: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
...sx
|
||||
}}>
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant={isLarge ? "h5" : (isSmall ? "caption" : "subtitle1")}
|
||||
variant={isLarge ? 'h5' : isSmall ? 'caption' : 'subtitle1'}
|
||||
sx={{ fontWeight: '900', lineHeight: 1, display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
{typeof score === 'number' ? (isLarge ? score.toFixed(1) : score.toFixed(0)) : score}
|
||||
@@ -39,11 +42,11 @@ const RatingBadge: React.FC<RatingBadgeProps> = ({ score, size = 'medium', sx })
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: isLarge ? '0.75rem' : (isSmall ? '0.55rem' : '0.65rem'),
|
||||
fontSize: isLarge ? '0.75rem' : isSmall ? '0.55rem' : '0.65rem',
|
||||
fontWeight: '900',
|
||||
opacity: 0.9,
|
||||
lineHeight: 1,
|
||||
mt: 0.2
|
||||
mt: 0.2,
|
||||
}}
|
||||
>
|
||||
/ 10
|
||||
|
||||
@@ -27,7 +27,7 @@ const RichText: React.FC<RichTextProps> = ({ text }) => {
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.1),
|
||||
padding: '0 4px',
|
||||
borderRadius: '4px'
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
{part}
|
||||
|
||||
@@ -10,7 +10,12 @@ interface StatusBadgeProps {
|
||||
sx?: any;
|
||||
}
|
||||
|
||||
const StatusBadge: React.FC<StatusBadgeProps> = ({ isLegacy, isAvailable, size = 'medium', sx }) => {
|
||||
const StatusBadge: React.FC<StatusBadgeProps> = ({
|
||||
isLegacy,
|
||||
isAvailable,
|
||||
size = 'medium',
|
||||
sx,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isSmall = size === 'small';
|
||||
|
||||
@@ -21,7 +26,8 @@ const StatusBadge: React.FC<StatusBadgeProps> = ({ isLegacy, isAvailable, size =
|
||||
const Icon = isLegacy ? HistoryIcon : BlockIcon;
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
@@ -34,8 +40,9 @@ const StatusBadge: React.FC<StatusBadgeProps> = ({ isLegacy, isAvailable, size =
|
||||
backdropFilter: 'blur(4px)',
|
||||
border: '1px solid',
|
||||
borderColor: alpha('#fff', 0.2),
|
||||
...sx
|
||||
}}>
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Icon sx={{ fontSize: isSmall ? '0.7rem' : '0.9rem' }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
@@ -44,7 +51,7 @@ const StatusBadge: React.FC<StatusBadgeProps> = ({ isLegacy, isAvailable, size =
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
fontSize: isSmall ? '0.55rem' : '0.65rem',
|
||||
lineHeight: 1
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createContext, useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import api, { clearTokens } from '../lib/api';
|
||||
|
||||
export interface AuthUser {
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
unread_notifications_count: number;
|
||||
is_superuser: boolean;
|
||||
theme?: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface FollowingEntry {
|
||||
id: number;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
export interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
setUser: (user: AuthUser | null) => void;
|
||||
following: FollowingEntry[];
|
||||
loadingUser: boolean;
|
||||
refetchUser: () => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { i18n } = useTranslation();
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [following, setFollowing] = useState<FollowingEntry[]>([]);
|
||||
const [loadingUser, setLoadingUser] = useState(true);
|
||||
|
||||
const refetchUser = useCallback(async () => {
|
||||
const token = localStorage.getItem('access');
|
||||
if (!token) {
|
||||
setUser(null);
|
||||
setLoadingUser(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.get('users/me/');
|
||||
if (res.data?.username) {
|
||||
setUser(res.data);
|
||||
if (res.data.theme) localStorage.setItem('theme', res.data.theme);
|
||||
if (res.data.language) i18n.changeLanguage(res.data.language);
|
||||
try {
|
||||
const followRes = await api.get('users/following_list/');
|
||||
const data = Array.isArray(followRes.data)
|
||||
? followRes.data
|
||||
: followRes.data.results || [];
|
||||
setFollowing(data);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
} catch {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoadingUser(false);
|
||||
}
|
||||
}, [i18n]);
|
||||
|
||||
useEffect(() => {
|
||||
refetchUser();
|
||||
}, [refetchUser]);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearTokens();
|
||||
setUser(null);
|
||||
setFollowing([]);
|
||||
i18n.changeLanguage(navigator.language.split('-')[0] || 'en');
|
||||
window.location.href = '/';
|
||||
}, [i18n]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, setUser, following, loadingUser, refetchUser, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createContext, useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||
import api from '../lib/api';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
export interface Notification {
|
||||
id: number;
|
||||
actor_username: string;
|
||||
actor_avatar: string | null;
|
||||
notification_type:
|
||||
| 'reply'
|
||||
| 'mention'
|
||||
| 'follow'
|
||||
| 'ticket_new'
|
||||
| 'ticket_reply'
|
||||
| 'profile_comment';
|
||||
rating: number | null;
|
||||
reply: number | null;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
flavor_name: string | null;
|
||||
flavor_id: number | null;
|
||||
profile_owner_username: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationContextValue {
|
||||
notifications: Notification[];
|
||||
fetchNotifications: () => Promise<void>;
|
||||
markAllRead: () => Promise<void>;
|
||||
markRead: (id: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export const NotificationContext = createContext<NotificationContextValue | null>(null);
|
||||
|
||||
export const NotificationProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { user, setUser, refetchUser } = useAuth();
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get('notifications/');
|
||||
const data = Array.isArray(res.data) ? res.data : res.data.results || [];
|
||||
setNotifications(data);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchNotifications();
|
||||
}
|
||||
}, [user, fetchNotifications]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (localStorage.getItem('access')) {
|
||||
fetchNotifications();
|
||||
refetchUser();
|
||||
}
|
||||
}, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchNotifications, refetchUser]);
|
||||
|
||||
const markAllRead = useCallback(async () => {
|
||||
try {
|
||||
await api.post('notifications/mark_all_read/');
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })));
|
||||
if (user) setUser({ ...user, unread_notifications_count: 0 });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [user, setUser]);
|
||||
|
||||
const markRead = useCallback(
|
||||
async (id: number) => {
|
||||
try {
|
||||
await api.post(`notifications/${id}/mark_read/`);
|
||||
setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)));
|
||||
if (user) {
|
||||
setUser({
|
||||
...user,
|
||||
unread_notifications_count: Math.max(0, user.unread_notifications_count - 1),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[user, setUser],
|
||||
);
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider
|
||||
value={{ notifications, fetchNotifications, markAllRead, markRead }}
|
||||
>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createContext, useCallback, useMemo, useState, type ReactNode } from 'react';
|
||||
import { ThemeProvider as MuiThemeProvider, CssBaseline } from '@mui/material';
|
||||
import { getTheme, type CatppuccinTheme } from '../theme';
|
||||
import api from '../lib/api';
|
||||
|
||||
export interface ThemeContextValue {
|
||||
themeName: CatppuccinTheme;
|
||||
setThemeName: (name: CatppuccinTheme) => void;
|
||||
handleThemeChange: (name: CatppuccinTheme, isLoggedIn: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [themeName, setThemeName] = useState<CatppuccinTheme>(
|
||||
(localStorage.getItem('theme') as CatppuccinTheme) || 'holy_light',
|
||||
);
|
||||
|
||||
const theme = useMemo(() => getTheme(themeName), [themeName]);
|
||||
|
||||
const handleThemeChange = useCallback(async (name: CatppuccinTheme, isLoggedIn: boolean) => {
|
||||
setThemeName(name);
|
||||
localStorage.setItem('theme', name);
|
||||
document.body.style.backgroundColor = getTheme(name).palette.background.default;
|
||||
document.body.style.color = getTheme(name).palette.text.primary;
|
||||
if (isLoggedIn) {
|
||||
try {
|
||||
await api.patch('users/update_preferences/', { theme: name });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ themeName, setThemeName, handleThemeChange }}>
|
||||
<MuiThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</MuiThemeProvider>
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useContext } from 'react';
|
||||
import { AuthContext } from '../contexts/AuthContext';
|
||||
|
||||
export const useAuth = () => {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import api from '../lib/api';
|
||||
|
||||
export interface SearchResult {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'flavor' | 'category' | 'user';
|
||||
subtitle: string;
|
||||
image_url: string | null;
|
||||
slug: string | null;
|
||||
}
|
||||
|
||||
export const useGlobalSearch = () => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [options, setOptions] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setOptions([]);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get(`flavors/search/?q=${encodeURIComponent(query)}`);
|
||||
const results = Array.isArray(res.data) ? res.data : res.data.results || [];
|
||||
setOptions(results);
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const q = params.get('q');
|
||||
if (q) setQuery(q);
|
||||
}, [location.search]);
|
||||
|
||||
return { query, setQuery, options, loading };
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useContext } from 'react';
|
||||
import { NotificationContext } from '../contexts/NotificationContext';
|
||||
|
||||
export const useNotifications = () => {
|
||||
const ctx = useContext(NotificationContext);
|
||||
if (!ctx) throw new Error('useNotifications must be used within NotificationProvider');
|
||||
return ctx;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useContext } from 'react';
|
||||
import { ThemeContext } from '../contexts/ThemeContext';
|
||||
|
||||
export const useTheme = () => {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
||||
return ctx;
|
||||
};
|
||||
+505
-456
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { clearTokens, getAccessToken, setTokens } from './api';
|
||||
|
||||
describe('token helpers', () => {
|
||||
beforeEach(() => {
|
||||
clearTokens();
|
||||
});
|
||||
|
||||
it('returns null when no access token is stored', () => {
|
||||
expect(getAccessToken()).toBeNull();
|
||||
});
|
||||
|
||||
it('stores and retrieves the access token', () => {
|
||||
setTokens('access-123', 'refresh-abc');
|
||||
expect(getAccessToken()).toBe('access-123');
|
||||
expect(localStorage.getItem('refresh')).toBe('refresh-abc');
|
||||
});
|
||||
|
||||
it('updates access without clobbering refresh when refresh is omitted', () => {
|
||||
setTokens('first-access', 'first-refresh');
|
||||
setTokens('second-access');
|
||||
expect(getAccessToken()).toBe('second-access');
|
||||
expect(localStorage.getItem('refresh')).toBe('first-refresh');
|
||||
});
|
||||
|
||||
it('clearTokens removes both access and refresh', () => {
|
||||
setTokens('a', 'b');
|
||||
clearTokens();
|
||||
expect(getAccessToken()).toBeNull();
|
||||
expect(localStorage.getItem('refresh')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import axios from 'axios';
|
||||
import type { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
|
||||
const ACCESS_KEY = 'access';
|
||||
const REFRESH_KEY = 'refresh';
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return localStorage.getItem(ACCESS_KEY);
|
||||
}
|
||||
|
||||
export function setTokens(access: string, refresh?: string): void {
|
||||
localStorage.setItem(ACCESS_KEY, access);
|
||||
if (refresh) {
|
||||
localStorage.setItem(REFRESH_KEY, refresh);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTokens(): void {
|
||||
localStorage.removeItem(ACCESS_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
}
|
||||
|
||||
/** One-time migration: remove the legacy DRF TokenAuth key from localStorage. */
|
||||
export function clearLegacyToken(): void {
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api/',
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const access = getAccessToken();
|
||||
if (access) {
|
||||
config.headers.Authorization = `Bearer ${access}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
type RetriableConfig = AxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
|
||||
async function refreshAccessToken(): Promise<string | null> {
|
||||
const refresh = localStorage.getItem(REFRESH_KEY);
|
||||
if (!refresh) return null;
|
||||
|
||||
try {
|
||||
const res = await axios.post(`${api.defaults.baseURL}auth/token/refresh/`, { refresh });
|
||||
const newAccess = res.data.access as string;
|
||||
const newRefresh = res.data.refresh as string | undefined;
|
||||
setTokens(newAccess, newRefresh);
|
||||
return newAccess;
|
||||
} catch {
|
||||
clearTokens();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const original = error.config as RetriableConfig | undefined;
|
||||
if (!original || error.response?.status !== 401 || original._retry) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const url = original.url ?? '';
|
||||
if (url.includes('auth/token/')) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
original._retry = true;
|
||||
refreshPromise = refreshPromise ?? refreshAccessToken();
|
||||
const newAccess = await refreshPromise;
|
||||
refreshPromise = null;
|
||||
|
||||
if (!newAccess) {
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
original.headers = {
|
||||
...(original.headers ?? {}),
|
||||
Authorization: `Bearer ${newAccess}`,
|
||||
};
|
||||
return api(original);
|
||||
},
|
||||
);
|
||||
|
||||
export default api;
|
||||
+12
-6
@@ -1,13 +1,19 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter as Router } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
import { AppProviders } from './app/providers';
|
||||
import { clearLegacyToken } from './lib/api';
|
||||
|
||||
clearLegacyToken();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<Router>
|
||||
<AppProviders>
|
||||
<App />
|
||||
</AppProviders>
|
||||
</Router>
|
||||
</StrictMode>,
|
||||
)
|
||||
);
|
||||
@@ -23,13 +23,21 @@ const About: React.FC = () => {
|
||||
</Button>
|
||||
|
||||
<Paper sx={{ p: { xs: 2.5, sm: 4, md: 6 }, borderRadius: 4, overflow: 'hidden' }}>
|
||||
<Typography variant={isMobile ? "h4" : "h3"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.title')}</Typography>
|
||||
<Typography variant="body1" paragraph sx={{ fontSize: { xs: '1rem', md: '1.1rem' }, lineHeight: 1.8 }}>
|
||||
<Typography variant={isMobile ? 'h4' : 'h3'} gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('about.title')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
paragraph
|
||||
sx={{ fontSize: { xs: '1rem', md: '1.1rem' }, lineHeight: 1.8 }}
|
||||
>
|
||||
{t('about.mission')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ my: 4 }}>
|
||||
<Typography variant={isMobile ? "h6" : "h5"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.canDoTitle')}</Typography>
|
||||
<Typography variant={isMobile ? 'h6' : 'h5'} gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('about.canDoTitle')}
|
||||
</Typography>
|
||||
<Typography component="ul" sx={{ lineHeight: 2, pl: 2 }}>
|
||||
<li>{t('about.featureExplore')}</li>
|
||||
<li>{t('about.featureRate')}</li>
|
||||
@@ -47,41 +55,69 @@ const About: React.FC = () => {
|
||||
|
||||
<Divider sx={{ my: { xs: 4, md: 6 } }} />
|
||||
|
||||
<Typography variant={isMobile ? "h5" : "h4"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.impressumTitle')}</Typography>
|
||||
<Typography variant={isMobile ? 'h5' : 'h4'} gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('about.impressumTitle')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 2 }}>
|
||||
<strong>{t('about.legalInfo')}</strong><br />
|
||||
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name or Project Name]'}<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}<br />
|
||||
<strong>{t('about.legalInfo')}</strong>
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name or Project Name]'}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}
|
||||
<br />
|
||||
{t('about.region')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('about.contact')}:</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||
{t('about.contact')}:
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
E-Mail: {import.meta.env.VITE_IMPRESSUM_EMAIL || '[Your Support Email Address]'}<br />
|
||||
E-Mail: {import.meta.env.VITE_IMPRESSUM_EMAIL || '[Your Support Email Address]'}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_PHONE && (
|
||||
<>Telefon: {import.meta.env.VITE_IMPRESSUM_PHONE}<br /></>
|
||||
<>
|
||||
Telefon: {import.meta.env.VITE_IMPRESSUM_PHONE}
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
Internet: holy.narl.io
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('about.responsible')}</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||
{t('about.responsible')}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'}<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.disclaimerTitle')}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" component="p" sx={{ mb: 2, lineHeight: 1.6 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('about.disclaimerTitle')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
component="p"
|
||||
sx={{ mb: 2, lineHeight: 1.6 }}
|
||||
>
|
||||
<strong>{t('about.liabilityContentTitle')}</strong> {t('about.liabilityContentDesc')}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" component="p" sx={{ lineHeight: 1.6 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
component="p"
|
||||
sx={{ lineHeight: 1.6 }}
|
||||
>
|
||||
<strong>{t('about.copyrightTitle')}</strong> {t('about.copyrightDesc')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -1,10 +1,37 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container, Typography, Box, Grid, Card, CardContent, Button,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Paper, Stack, alpha, CircularProgress, Chip, TextField, IconButton,
|
||||
useMediaQuery, Tabs, Tab, List, ListItem, ListItemText, Switch, Divider,
|
||||
Alert, Select, MenuItem, FormControl
|
||||
Container,
|
||||
Typography,
|
||||
Box,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Stack,
|
||||
alpha,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
TextField,
|
||||
IconButton,
|
||||
useMediaQuery,
|
||||
Tabs,
|
||||
Tab,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Switch,
|
||||
Divider,
|
||||
Alert,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
} from '@mui/material';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import EmailIcon from '@mui/icons-material/Email';
|
||||
@@ -17,7 +44,7 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import api from '../lib/api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { formatDate } from '../utils/date';
|
||||
import BannerSettingsDialog from '../components/BannerSettingsDialog';
|
||||
@@ -108,13 +135,15 @@ const AdminDashboard: React.FC = () => {
|
||||
api.get('admin-custom/users/'),
|
||||
api.get('banners/'),
|
||||
api.get('admin-custom/jobs/'),
|
||||
api.get('admin-custom/config/')
|
||||
api.get('admin-custom/config/'),
|
||||
]);
|
||||
|
||||
setStats(statsRes.data);
|
||||
setUsers(usersRes.data);
|
||||
|
||||
const bannersData = Array.isArray(bannersRes.data) ? bannersRes.data : (bannersRes.data.results || []);
|
||||
const bannersData = Array.isArray(bannersRes.data)
|
||||
? bannersRes.data
|
||||
: bannersRes.data.results || [];
|
||||
setBanners(bannersData);
|
||||
|
||||
setJobs(jobsRes.data);
|
||||
@@ -128,7 +157,7 @@ const AdminDashboard: React.FC = () => {
|
||||
|
||||
// Poll for job updates if any are running or pending
|
||||
useEffect(() => {
|
||||
const anyRunning = jobs.some(j => j.status === 'running' || j.status === 'pending');
|
||||
const anyRunning = jobs.some((j) => j.status === 'running' || j.status === 'pending');
|
||||
if (anyRunning && currentTab === 3) {
|
||||
const interval = setInterval(() => fetchData(true), 3000);
|
||||
return () => clearInterval(interval);
|
||||
@@ -195,12 +224,18 @@ const AdminDashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(u =>
|
||||
const filteredUsers = users.filter(
|
||||
(u) =>
|
||||
u.username.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(search.toLowerCase())
|
||||
u.email.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
|
||||
if (loading)
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
@@ -212,13 +247,15 @@ const AdminDashboard: React.FC = () => {
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Typography variant={isMobile ? "h4" : "h3"} sx={{ fontWeight: 'bold', mb: 4 }}>{t('admin.title')}</Typography>
|
||||
<Typography variant={isMobile ? 'h4' : 'h3'} sx={{ fontWeight: 'bold', mb: 4 }}>
|
||||
{t('admin.title')}
|
||||
</Typography>
|
||||
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
onChange={(_, v) => setCurrentTab(v)}
|
||||
sx={{ mb: 4, borderBottom: 1, borderColor: 'divider' }}
|
||||
variant={isMobile ? "scrollable" : "standard"}
|
||||
variant={isMobile ? 'scrollable' : 'standard'}
|
||||
>
|
||||
<Tab icon={<SettingsIcon />} label={t('admin.overview')} iconPosition="start" />
|
||||
<Tab icon={<PeopleIcon />} label={t('admin.users')} iconPosition="start" />
|
||||
@@ -232,32 +269,60 @@ const AdminDashboard: React.FC = () => {
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card sx={{ bgcolor: (theme) => alpha(theme.palette.primary.main, 0.1) }}>
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>{t('admin.totalUsers')}</Typography>
|
||||
<Typography variant={isMobile ? "h5" : "h4"} sx={{ fontWeight: 'bold' }}>{stats?.total_users}</Typography>
|
||||
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>
|
||||
{t('admin.totalUsers')}
|
||||
</Typography>
|
||||
<Typography variant={isMobile ? 'h5' : 'h4'} sx={{ fontWeight: 'bold' }}>
|
||||
{stats?.total_users}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card sx={{ bgcolor: (theme) => alpha(theme.palette.error.main, 0.1) }}>
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>{t('admin.openTickets')}</Typography>
|
||||
<Typography variant={isMobile ? "h5" : "h4"} sx={{ fontWeight: 'bold', color: 'error.main' }}>{stats?.open_tickets}</Typography>
|
||||
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>
|
||||
{t('admin.openTickets')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant={isMobile ? 'h5' : 'h4'}
|
||||
sx={{ fontWeight: 'bold', color: 'error.main' }}
|
||||
>
|
||||
{stats?.open_tickets}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Stack direction={isMobile ? "column" : "row"} justifyContent="space-between" alignItems={isMobile ? "flex-start" : "center"} spacing={2}>
|
||||
<Stack
|
||||
direction={isMobile ? 'column' : 'row'}
|
||||
justifyContent="space-between"
|
||||
alignItems={isMobile ? 'flex-start' : 'center'}
|
||||
spacing={2}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{t('admin.config')}: SMTP</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{stats?.email_config.host}:{stats?.email_config.port} •
|
||||
TLS: {String(stats?.email_config.use_tls)} •
|
||||
Insecure: {String(stats?.email_config.skip_verify)}
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||
{t('admin.config')}: SMTP
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block' }}
|
||||
>
|
||||
{stats?.email_config.host}:{stats?.email_config.port} • TLS:{' '}
|
||||
{String(stats?.email_config.use_tls)} • Insecure:{' '}
|
||||
{String(stats?.email_config.skip_verify)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="outlined" fullWidth={isMobile} startIcon={<EmailIcon />} onClick={handleSendTestEmail} size="small">
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth={isMobile}
|
||||
startIcon={<EmailIcon />}
|
||||
onClick={handleSendTestEmail}
|
||||
size="small"
|
||||
>
|
||||
{t('admin.sendTestEmail')}
|
||||
</Button>
|
||||
</Stack>
|
||||
@@ -269,14 +334,20 @@ const AdminDashboard: React.FC = () => {
|
||||
<Grid container spacing={4}>
|
||||
{/* Live Config */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', mb: 2 }}>System Configuration (Live)</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', mb: 2 }}>
|
||||
System Configuration (Live)
|
||||
</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 0, borderRadius: 3, overflow: 'hidden' }}>
|
||||
<List disablePadding>
|
||||
{[
|
||||
{ key: 'site_name', label: 'Site Name', type: 'text' },
|
||||
{ key: 'maintenance_mode', label: 'Maintenance Mode', type: 'switch' },
|
||||
{ key: 'allow_new_signups', label: 'Allow Signups', type: 'switch' },
|
||||
{ key: 'require_email_verification', label: 'Require Email Verify', type: 'switch' }
|
||||
{
|
||||
key: 'require_email_verification',
|
||||
label: 'Require Email Verify',
|
||||
type: 'switch',
|
||||
},
|
||||
].map((item, idx) => (
|
||||
<ListItem key={item.key} divider={idx < 3} sx={{ py: 2 }}>
|
||||
<ListItemText
|
||||
@@ -286,13 +357,23 @@ const AdminDashboard: React.FC = () => {
|
||||
{item.type === 'switch' ? (
|
||||
<Switch
|
||||
checked={(config as any)?.[item.key] || false}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleUpdateConfig(item.key, e.target.checked)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
handleUpdateConfig(item.key, e.target.checked)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Button size="small" onClick={() => {
|
||||
const val = prompt(`Enter new value for ${item.label}:`, (config as any)?.[item.key]);
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const val = prompt(
|
||||
`Enter new value for ${item.label}:`,
|
||||
(config as any)?.[item.key],
|
||||
);
|
||||
if (val !== null) handleUpdateConfig(item.key, val);
|
||||
}}>Edit</Button>
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
@@ -302,27 +383,56 @@ const AdminDashboard: React.FC = () => {
|
||||
|
||||
{/* Environment Info */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', mb: 2 }}>Environment Info (.env)</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 3, borderRadius: 3, bgcolor: alpha('#000', 0.02) }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', mb: 2 }}>
|
||||
Environment Info (.env)
|
||||
</Typography>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{ p: 3, borderRadius: 3, bgcolor: alpha('#000', 0.02) }}
|
||||
>
|
||||
<Stack spacing={2.5}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}>API Status</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>DEBUG={String(stats?.server_info.debug)}</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}
|
||||
>
|
||||
API Status
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||
DEBUG={String(stats?.server_info.debug)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}>Frontend URL</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>{stats?.server_info.frontend_url}</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}
|
||||
>
|
||||
Frontend URL
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||
{stats?.server_info.frontend_url}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}>Allowed Hosts</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>{stats?.server_info.allowed_hosts.join(', ')}</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}
|
||||
>
|
||||
Allowed Hosts
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||
{stats?.server_info.allowed_hosts.join(', ')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
||||
<InfoIcon fontSize="small" color="primary" />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
These values are read from your server environment or .env file.
|
||||
To change them, you must update the file and restart the backend container.
|
||||
These values are read from your server environment or .env file. To change
|
||||
them, you must update the file and restart the backend container.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
@@ -334,38 +444,70 @@ const AdminDashboard: React.FC = () => {
|
||||
|
||||
{currentTab === 1 && (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Stack direction={isMobile ? "column" : "row"} justifyContent="space-between" alignItems={isMobile ? "flex-start" : "center"} sx={{ mb: 2 }} spacing={2}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>{t('admin.users')}</Typography>
|
||||
<Stack
|
||||
direction={isMobile ? 'column' : 'row'}
|
||||
justifyContent="space-between"
|
||||
alignItems={isMobile ? 'flex-start' : 'center'}
|
||||
sx={{ mb: 2 }}
|
||||
spacing={2}
|
||||
>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||
{t('admin.users')}
|
||||
</Typography>
|
||||
<TextField
|
||||
size="small" placeholder="Search..."
|
||||
size="small"
|
||||
placeholder="Search..."
|
||||
fullWidth={isMobile}
|
||||
value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ width: isMobile ? '100%' : 300 }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{isMobile ? (
|
||||
<Stack spacing={2}>
|
||||
{filteredUsers.map(user => (
|
||||
{filteredUsers.map((user) => (
|
||||
<Card key={user.id} variant="outlined" sx={{ borderRadius: 3 }}>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{user.username}</Typography>
|
||||
<IconButton size="small" onClick={() => navigate(`/admin-panel/user/${user.id}`)}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||
{user.username}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => navigate(`/admin-panel/user/${user.id}`)}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" display="block">{user.email}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{user.email}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||
<Chip label={user.is_active ? 'Active' : 'Inactive'} color={user.is_active ? 'success' : 'default'} size="small" sx={{ height: 20, fontSize: '0.65rem' }} />
|
||||
{user.is_superuser && <Chip label="Admin" color="secondary" size="small" sx={{ height: 20, fontSize: '0.65rem' }} />}
|
||||
<Chip
|
||||
label={user.is_active ? 'Active' : 'Inactive'}
|
||||
color={user.is_active ? 'success' : 'default'}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.65rem' }}
|
||||
/>
|
||||
{user.is_superuser && (
|
||||
<Chip
|
||||
label="Admin"
|
||||
color="secondary"
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.65rem' }}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<TableContainer component={Paper} sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}>
|
||||
<TableContainer
|
||||
component={Paper}
|
||||
sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<Table>
|
||||
<TableHead sx={{ bgcolor: 'action.hover' }}>
|
||||
<TableRow>
|
||||
@@ -375,7 +517,9 @@ const AdminDashboard: React.FC = () => {
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Status</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Joined</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>IP History</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }} align="right">Actions</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }} align="right">
|
||||
Actions
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -383,8 +527,17 @@ const AdminDashboard: React.FC = () => {
|
||||
<TableRow key={user.id} hover>
|
||||
<TableCell>{user.id}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{user.username}</Typography>
|
||||
{user.is_superuser && <Chip label="Superuser" size="small" color="secondary" sx={{ height: 16, fontSize: '0.6rem' }} />}
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
{user.username}
|
||||
</Typography>
|
||||
{user.is_superuser && (
|
||||
<Chip
|
||||
label="Superuser"
|
||||
size="small"
|
||||
color="secondary"
|
||||
sx={{ height: 16, fontSize: '0.6rem' }}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
@@ -397,14 +550,25 @@ const AdminDashboard: React.FC = () => {
|
||||
<TableCell>{formatDate(user.date_joined)}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap">
|
||||
{user.ips.slice(0, 3).map(ip => (
|
||||
<Chip key={ip} label={ip} size="small" variant="outlined" sx={{ fontSize: '0.7rem' }} />
|
||||
{user.ips.slice(0, 3).map((ip) => (
|
||||
<Chip
|
||||
key={ip}
|
||||
label={ip}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ fontSize: '0.7rem' }}
|
||||
/>
|
||||
))}
|
||||
{user.ips.length > 3 && <Typography variant="caption">+{user.ips.length - 3}</Typography>}
|
||||
{user.ips.length > 3 && (
|
||||
<Typography variant="caption">+{user.ips.length - 3}</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={() => navigate(`/admin-panel/user/${user.id}`)}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => navigate(`/admin-panel/user/${user.id}`)}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
@@ -419,41 +583,67 @@ const AdminDashboard: React.FC = () => {
|
||||
|
||||
{currentTab === 2 && (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold', mb: 3 }}>{t('admin.banners')} ({banners.length})</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold', mb: 3 }}>
|
||||
{t('admin.banners')} ({banners.length})
|
||||
</Typography>
|
||||
|
||||
{banners.length === 0 ? (
|
||||
<Card variant="outlined" sx={{ p: 4, textAlign: 'center', borderRadius: 3 }}>
|
||||
<Typography color="text.secondary">No banner models found in the database.</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
sx={{ mt: 2 }}
|
||||
onClick={() => fetchData()}
|
||||
>
|
||||
<Typography color="text.secondary">
|
||||
No banner models found in the database.
|
||||
</Typography>
|
||||
<Button variant="outlined" sx={{ mt: 2 }} onClick={() => fetchData()}>
|
||||
Retry Loading
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
{banners.map((banner) => (
|
||||
<Card key={banner.id} sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Card
|
||||
key={banner.id}
|
||||
sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>{banner.name} (Slug: {banner.slug})</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{banner.name} (Slug: {banner.slug})
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{!banner.is_enabled && <Chip label="Hidden from Users" color="warning" size="small" variant="outlined" />}
|
||||
{banner.is_active && <Chip label="Global Default" color="success" size="small" />}
|
||||
{!banner.is_enabled && (
|
||||
<Chip
|
||||
label="Hidden from Users"
|
||||
color="warning"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
{banner.is_active && (
|
||||
<Chip label="Global Default" color="success" size="small" />
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>{banner.description}</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
{banner.description}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ p: 2, bgcolor: 'action.hover', borderRadius: 2, mb: 2 }}>
|
||||
<pre style={{ margin: 0, fontSize: '0.8rem' }}>{JSON.stringify(banner.settings, null, 2)}</pre>
|
||||
<pre style={{ margin: 0, fontSize: '0.8rem' }}>
|
||||
{JSON.stringify(banner.settings, null, 2)}
|
||||
</pre>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
|
||||
<Button
|
||||
variant="outlined" size="small"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setSelectedBanner(banner);
|
||||
setIsSettingsDialogOpen(true);
|
||||
@@ -462,15 +652,18 @@ const AdminDashboard: React.FC = () => {
|
||||
Edit Settings
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined" size="small" color={banner.is_enabled ? "warning" : "success"}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
color={banner.is_enabled ? 'warning' : 'success'}
|
||||
onClick={() => handleToggleEnabled(banner.id)}
|
||||
disabled={banner.is_active}
|
||||
>
|
||||
{banner.is_enabled ? "Disable for Users" : "Enable for Users"}
|
||||
{banner.is_enabled ? 'Disable for Users' : 'Enable for Users'}
|
||||
</Button>
|
||||
{!banner.is_active && (
|
||||
<Button
|
||||
variant="contained" size="small"
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => handleActivateBanner(banner.id)}
|
||||
>
|
||||
Set as Default
|
||||
@@ -487,32 +680,73 @@ const AdminDashboard: React.FC = () => {
|
||||
|
||||
{currentTab === 3 && (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold', mb: 3 }}>Background Worker Jobs</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold', mb: 3 }}>
|
||||
Background Worker Jobs
|
||||
</Typography>
|
||||
<Stack spacing={3}>
|
||||
{jobs.map(job => (
|
||||
<Card key={job.id} sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}>
|
||||
{jobs.map((job) => (
|
||||
<Card
|
||||
key={job.id}
|
||||
sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>{job.name_display}</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{job.name_display}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={job.status === 'pending' ? 'QUEUED' : job.status.toUpperCase()}
|
||||
size="small"
|
||||
color={job.status === 'completed' ? 'success' : job.status === 'running' ? 'info' : job.status === 'failed' ? 'error' : 'default'}
|
||||
color={
|
||||
job.status === 'completed'
|
||||
? 'success'
|
||||
: job.status === 'running'
|
||||
? 'info'
|
||||
: job.status === 'failed'
|
||||
? 'error'
|
||||
: 'default'
|
||||
}
|
||||
variant={job.status === 'running' ? 'filled' : 'outlined'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">Last Run</Typography>
|
||||
<Typography variant="body2">{job.last_run ? formatDate(job.last_run) : 'Never'}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
Last Run
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{job.last_run ? formatDate(job.last_run) : 'Never'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">Next Run</Typography>
|
||||
<Typography variant="body2">{job.next_run ? formatDate(job.next_run) : (job.interval_hours > 0 ? 'Queued...' : 'Disabled')}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
Next Run
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{job.next_run
|
||||
? formatDate(job.next_run)
|
||||
: job.interval_hours > 0
|
||||
? 'Queued...'
|
||||
: 'Disabled'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 0.5 }}>Schedule</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
display="block"
|
||||
sx={{ mb: 0.5 }}
|
||||
>
|
||||
Schedule
|
||||
</Typography>
|
||||
<FormControl size="small" fullWidth>
|
||||
<Select
|
||||
value={job.interval_hours}
|
||||
@@ -532,17 +766,47 @@ const AdminDashboard: React.FC = () => {
|
||||
|
||||
{job.last_output && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block" gutterBottom>Last Run Output</Typography>
|
||||
<Box sx={{ p: 1.5, bgcolor: '#1e1e1e', color: '#d4d4d4', borderRadius: 2, maxHeight: 150, overflowY: 'auto', border: '1px solid #333' }}>
|
||||
<pre style={{ margin: 0, fontSize: '0.75rem', fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>{job.last_output}</pre>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
display="block"
|
||||
gutterBottom
|
||||
>
|
||||
Last Run Output
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
bgcolor: '#1e1e1e',
|
||||
color: '#d4d4d4',
|
||||
borderRadius: 2,
|
||||
maxHeight: 150,
|
||||
overflowY: 'auto',
|
||||
border: '1px solid #333',
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '0.75rem',
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{job.last_output}
|
||||
</pre>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{job.error_message && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" color="error" display="block" gutterBottom>Last Error</Typography>
|
||||
<Alert severity="error" sx={{ py: 0 }}>{job.error_message}</Alert>
|
||||
<Typography variant="caption" color="error" display="block" gutterBottom>
|
||||
Last Error
|
||||
</Typography>
|
||||
<Alert severity="error" sx={{ py: 0 }}>
|
||||
{job.error_message}
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -575,4 +839,3 @@ const AdminDashboard: React.FC = () => {
|
||||
};
|
||||
|
||||
export default AdminDashboard;
|
||||
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user