From e4d59a13ba5b7a458bd3ef2f7b20897d70b53ee7 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 11 Apr 2026 10:37:49 +0200 Subject: [PATCH 01/26] refactor --- .env.example | 13 +- .gitea/workflows/ci.yml | 59 +- backend/api/admin.py | 13 +- backend/api/apps.py | 2 +- backend/api/email_backend.py | 7 +- backend/api/management/commands/backup_db.py | 62 +- .../management/commands/cleanup_duplicates.py | 39 +- .../api/management/commands/seed_banners.py | 55 +- .../commands/seed_legacy_flavors.py | 135 +- .../api/management/commands/sync_flavors.py | 170 +- backend/api/management/commands/worker.py | 81 - .../api/migrations/0030_flavor_image_urls.py | 18 + backend/api/models.py | 266 --- backend/api/models/__init__.py | 22 + backend/api/models/flavor.py | 51 + backend/api/models/rating.py | 30 + backend/api/models/social.py | 45 + backend/api/models/support.py | 29 + backend/api/models/system.py | 77 + backend/api/models/user.py | 72 + backend/api/serializers.py | 237 --- backend/api/serializers/__init__.py | 33 + backend/api/serializers/_helpers.py | 10 + backend/api/serializers/flavor.py | 56 + backend/api/serializers/rating.py | 54 + backend/api/serializers/social.py | 64 + backend/api/serializers/support.py | 40 + backend/api/serializers/system.py | 57 + backend/api/serializers/user.py | 95 ++ backend/api/services/__init__.py | 0 backend/api/services/ip_logging.py | 14 + backend/api/services/mentions.py | 29 + backend/api/services/search.py | 44 + backend/api/tasks.py | 96 ++ backend/api/tests.py | 45 - backend/api/tests/__init__.py | 0 backend/api/tests/test_auth.py | 55 + backend/api/tests/test_flavors.py | 45 + backend/api/tests/test_models.py | 19 + backend/api/urls.py | 23 +- backend/api/views.py | 979 ----------- backend/api/views/__init__.py | 20 + backend/api/views/_pagination.py | 5 + backend/api/views/admin.py | 145 ++ backend/api/views/banner.py | 65 + backend/api/views/flavor.py | 105 ++ backend/api/views/rating.py | 120 ++ backend/api/views/support.py | 81 + backend/api/views/user.py | 546 +++++++ backend/holy_backend/__init__.py | 3 + backend/holy_backend/asgi.py | 2 +- backend/holy_backend/celery.py | 15 + backend/holy_backend/settings.py | 231 --- backend/holy_backend/settings/__init__.py | 0 backend/holy_backend/settings/base.py | 191 +++ backend/holy_backend/settings/dev.py | 18 + backend/holy_backend/settings/prod.py | 22 + backend/holy_backend/urls.py | 53 +- backend/holy_backend/wsgi.py | 2 +- backend/manage.py | 5 +- backend/pyproject.toml | 49 + backend/requirements-dev.txt | 14 + backend/requirements.txt | 4 + docker-compose.yml | 55 +- frontend/.prettierignore | 5 + frontend/.prettierrc | 8 + frontend/eslint.config.js | 13 + frontend/package-lock.json | 1244 +++++++++++++- frontend/package.json | 15 +- frontend/src/App.tsx | 1116 ++++++------- frontend/src/api.ts | 42 - frontend/src/app/GlobalSearch.tsx | 121 ++ frontend/src/app/providers.tsx | 13 + .../src/components/BannerSettingsDialog.tsx | 287 ++-- frontend/src/components/CookieBanner.tsx | 197 +-- frontend/src/components/DynamicBanner.tsx | 167 +- frontend/src/components/FirefliesBanner.tsx | 471 +++--- frontend/src/components/Footer.tsx | 67 +- frontend/src/components/GenerativeBanner.tsx | 542 ++++--- .../src/components/GenerativeFlowBanner.tsx | 400 ++--- frontend/src/components/HextechBanner.tsx | 505 +++--- .../components/HextechCorruptionBanner.tsx | 478 +++--- frontend/src/components/MentionTextField.tsx | 365 +++-- frontend/src/components/NebulaBanner.tsx | 577 ++++--- frontend/src/components/RatingBadge.tsx | 93 +- frontend/src/components/RichText.tsx | 60 +- frontend/src/components/StatusBadge.tsx | 91 +- frontend/src/contexts/AuthContext.tsx | 86 + frontend/src/contexts/NotificationContext.tsx | 99 ++ frontend/src/contexts/ThemeContext.tsx | 43 + frontend/src/hooks/useAuth.ts | 8 + frontend/src/hooks/useGlobalSearch.ts | 47 + frontend/src/hooks/useNotifications.ts | 8 + frontend/src/hooks/useTheme.ts | 8 + frontend/src/i18n.ts | 961 +++++------ frontend/src/lib/api.test.ts | 32 + frontend/src/lib/api.ts | 93 ++ frontend/src/main.tsx | 20 +- frontend/src/pages/About.tsx | 128 +- frontend/src/pages/AdminDashboard.tsx | 1319 +++++++++------ frontend/src/pages/AdminRatingDetail.tsx | 180 ++- frontend/src/pages/AdminReplyDetail.tsx | 159 +- frontend/src/pages/AdminUserDetail.tsx | 756 +++++---- frontend/src/pages/CommunityFeed.tsx | 900 +++++++---- frontend/src/pages/Dashboard.tsx | 1379 ++++++++++------ frontend/src/pages/FlavorList.tsx | 290 ++-- frontend/src/pages/Login.tsx | 457 +++--- frontend/src/pages/MainPage.tsx | 1436 +++++++++++------ frontend/src/pages/Privacy.tsx | 214 ++- frontend/src/pages/PublicProfile.tsx | 1408 ++++++++++------ frontend/src/pages/Settings.tsx | 643 ++++---- frontend/src/pages/Support.tsx | 777 +++++---- frontend/src/pages/VerifyEmail.tsx | 172 +- .../src/pages/categories/CategoryFlavors.tsx | 198 ++- frontend/src/pages/flavors/FlavorDetail.tsx | 1230 ++++++++------ frontend/src/test/setup.ts | 31 + frontend/src/theme.ts | 486 +++--- frontend/src/utils/date.ts | 16 +- frontend/vitest.config.ts | 12 + 119 files changed, 15490 insertions(+), 9975 deletions(-) delete mode 100644 backend/api/management/commands/worker.py create mode 100644 backend/api/migrations/0030_flavor_image_urls.py delete mode 100644 backend/api/models.py create mode 100644 backend/api/models/__init__.py create mode 100644 backend/api/models/flavor.py create mode 100644 backend/api/models/rating.py create mode 100644 backend/api/models/social.py create mode 100644 backend/api/models/support.py create mode 100644 backend/api/models/system.py create mode 100644 backend/api/models/user.py delete mode 100644 backend/api/serializers.py create mode 100644 backend/api/serializers/__init__.py create mode 100644 backend/api/serializers/_helpers.py create mode 100644 backend/api/serializers/flavor.py create mode 100644 backend/api/serializers/rating.py create mode 100644 backend/api/serializers/social.py create mode 100644 backend/api/serializers/support.py create mode 100644 backend/api/serializers/system.py create mode 100644 backend/api/serializers/user.py create mode 100644 backend/api/services/__init__.py create mode 100644 backend/api/services/ip_logging.py create mode 100644 backend/api/services/mentions.py create mode 100644 backend/api/services/search.py create mode 100644 backend/api/tasks.py delete mode 100644 backend/api/tests.py create mode 100644 backend/api/tests/__init__.py create mode 100644 backend/api/tests/test_auth.py create mode 100644 backend/api/tests/test_flavors.py create mode 100644 backend/api/tests/test_models.py delete mode 100644 backend/api/views.py create mode 100644 backend/api/views/__init__.py create mode 100644 backend/api/views/_pagination.py create mode 100644 backend/api/views/admin.py create mode 100644 backend/api/views/banner.py create mode 100644 backend/api/views/flavor.py create mode 100644 backend/api/views/rating.py create mode 100644 backend/api/views/support.py create mode 100644 backend/api/views/user.py create mode 100644 backend/holy_backend/celery.py delete mode 100644 backend/holy_backend/settings.py create mode 100644 backend/holy_backend/settings/__init__.py create mode 100644 backend/holy_backend/settings/base.py create mode 100644 backend/holy_backend/settings/dev.py create mode 100644 backend/holy_backend/settings/prod.py create mode 100644 backend/pyproject.toml create mode 100644 backend/requirements-dev.txt create mode 100644 frontend/.prettierignore create mode 100644 frontend/.prettierrc delete mode 100644 frontend/src/api.ts create mode 100644 frontend/src/app/GlobalSearch.tsx create mode 100644 frontend/src/app/providers.tsx create mode 100644 frontend/src/contexts/AuthContext.tsx create mode 100644 frontend/src/contexts/NotificationContext.tsx create mode 100644 frontend/src/contexts/ThemeContext.tsx create mode 100644 frontend/src/hooks/useAuth.ts create mode 100644 frontend/src/hooks/useGlobalSearch.ts create mode 100644 frontend/src/hooks/useNotifications.ts create mode 100644 frontend/src/hooks/useTheme.ts create mode 100644 frontend/src/lib/api.test.ts create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/test/setup.ts create mode 100644 frontend/vitest.config.ts diff --git a/.env.example b/.env.example index e41f202..b8153be 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e44495e..2d00c23 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -8,23 +8,40 @@ on: jobs: frontend-build: - runs-on: ubuntu-latest - container: - image: catthehacker/ubuntu:act-latest - steps: + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + steps: - uses: actions/checkout@v4 - + - name: Setup Node.js uses: actions/setup-node@v4 with: 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 diff --git a/backend/api/admin.py b/backend/api/admin.py index b0c3e81..13c5a20 100644 --- a/backend/api/admin.py +++ b/backend/api/admin.py @@ -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) diff --git a/backend/api/apps.py b/backend/api/apps.py index d87006d..14b89a8 100644 --- a/backend/api/apps.py +++ b/backend/api/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class ApiConfig(AppConfig): - name = 'api' + name = "api" diff --git a/backend/api/email_backend.py b/backend/api/email_backend.py index 8abba7f..45ef04a 100644 --- a/backend/api/email_backend.py +++ b/backend/api/email_backend.py @@ -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 diff --git a/backend/api/management/commands/backup_db.py b/backend/api/management/commands/backup_db.py index 55f262e..b8575e5 100644 --- a/backend/api/management/commands/backup_db.py +++ b/backend/api/management/commands/backup_db.py @@ -1,38 +1,43 @@ -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}...") - + try: src = sqlite3.connect(db_path) dst = sqlite3.connect(db_backup_path) @@ -46,12 +51,13 @@ 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}...") - + try: with tarfile.open(archive_path, "w:gz") as tar: # Add database @@ -60,19 +66,19 @@ class Command(BaseCommand): media_dir = str(settings.MEDIA_ROOT) if os.path.exists(media_dir): 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...") for filename in os.listdir(backup_dir): file_path = os.path.join(backup_dir, filename) @@ -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}") + ) diff --git a/backend/api/management/commands/cleanup_duplicates.py b/backend/api/management/commands/cleanup_duplicates.py index effb8e9..bc72cca 100644 --- a/backend/api/management/commands/cleanup_duplicates.py +++ b/backend/api/management/commands/cleanup_duplicates.py @@ -1,34 +1,39 @@ 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 for rating in Rating.objects.filter(flavor=other): @@ -39,7 +44,7 @@ class Command(BaseCommand): # User already has a rating for the 'keep' flavor # Delete the duplicate rating instead rating.delete() - + # Delete the duplicate flavor other.delete() diff --git a/backend/api/management/commands/seed_banners.py b/backend/api/management/commands/seed_banners.py index d1b6b3a..4368bad 100644 --- a/backend/api/management/commands/seed_banners.py +++ b/backend/api/management/commands/seed_banners.py @@ -1,64 +1,75 @@ -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 for key, value in json_settings.items(): if key not in db_settings: db_settings[key] = value updated = True - + # Always sync schema as it represents the UI structure if banner.schema != json_schema: banner.schema = json_schema updated = True - + 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}") + ) diff --git a/backend/api/management/commands/seed_legacy_flavors.py b/backend/api/management/commands/seed_legacy_flavors.py index 3508e66..ee8f000 100644 --- a/backend/api/management/commands/seed_legacy_flavors.py +++ b/backend/api/management/commands/seed_legacy_flavors.py @@ -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,88 +47,103 @@ 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: file_path = os.path.join(legacy_dir, json_file) 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}" - + # Determine final category cat = file_cat 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() - + # If it exists and is an active Shopify product, skip legacy processing if flavor and not flavor.is_legacy: self.stdout.write(f"Skipping active flavor: {name}") 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}") flavor.description = full_desc - flavor.is_available = False # Legacy is always unavailable via API + flavor.is_available = False # Legacy is always unavailable via API if image_url: # Check if local file exists @@ -140,9 +159,11 @@ class Command(BaseCommand): if rel_path: flavor.image = rel_path self.stdout.write(f" -> Updated image for: {name}") - + flavor.image_url = image_url 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.") + ) diff --git a/backend/api/management/commands/sync_flavors.py b/backend/api/management/commands/sync_flavors.py index 79969bb..6129168 100644 --- a/backend/api/management/commands/sync_flavors.py +++ b/backend/api/management/commands/sync_flavors.py @@ -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,50 +24,60 @@ 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): url = "https://weareholy.com/products.json?limit=250" 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,82 +85,97 @@ 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 # REMOVED: is_legacy=False flavor = Flavor.objects.filter(name=title, category=category).first() - + if not flavor: # 3. Try case-insensitive name match in category (safety for whitespace/case) # REMOVED: is_legacy=False 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 flavor.shop_url = shop_url flavor.is_available = is_available - + if image_url: # Check if local file exists file_exists = False @@ -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}" + ) + ) diff --git a/backend/api/management/commands/worker.py b/backend/api/management/commands/worker.py deleted file mode 100644 index b00934f..0000000 --- a/backend/api/management/commands/worker.py +++ /dev/null @@ -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}")) diff --git a/backend/api/migrations/0030_flavor_image_urls.py b/backend/api/migrations/0030_flavor_image_urls.py new file mode 100644 index 0000000..569d61e --- /dev/null +++ b/backend/api/migrations/0030_flavor_image_urls.py @@ -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), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py deleted file mode 100644 index 0a86957..0000000 --- a/backend/api/models.py +++ /dev/null @@ -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})" diff --git a/backend/api/models/__init__.py b/backend/api/models/__init__.py new file mode 100644 index 0000000..cc627e7 --- /dev/null +++ b/backend/api/models/__init__.py @@ -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", +] diff --git a/backend/api/models/flavor.py b/backend/api/models/flavor.py new file mode 100644 index 0000000..18601b8 --- /dev/null +++ b/backend/api/models/flavor.py @@ -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 diff --git a/backend/api/models/rating.py b/backend/api/models/rating.py new file mode 100644 index 0000000..1947676 --- /dev/null +++ b/backend/api/models/rating.py @@ -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}" diff --git a/backend/api/models/social.py b/backend/api/models/social.py new file mode 100644 index 0000000..c12f2c3 --- /dev/null +++ b/backend/api/models/social.py @@ -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"] diff --git a/backend/api/models/support.py b/backend/api/models/support.py new file mode 100644 index 0000000..789d473 --- /dev/null +++ b/backend/api/models/support.py @@ -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"] diff --git a/backend/api/models/system.py b/backend/api/models/system.py new file mode 100644 index 0000000..3d48686 --- /dev/null +++ b/backend/api/models/system.py @@ -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})" diff --git a/backend/api/models/user.py b/backend/api/models/user.py new file mode 100644 index 0000000..255e077 --- /dev/null +++ b/backend/api/models/user.py @@ -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"] diff --git a/backend/api/serializers.py b/backend/api/serializers.py deleted file mode 100644 index 14193b2..0000000 --- a/backend/api/serializers.py +++ /dev/null @@ -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 diff --git a/backend/api/serializers/__init__.py b/backend/api/serializers/__init__.py new file mode 100644 index 0000000..220c8de --- /dev/null +++ b/backend/api/serializers/__init__.py @@ -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", +] diff --git a/backend/api/serializers/_helpers.py b/backend/api/serializers/_helpers.py new file mode 100644 index 0000000..268bfdc --- /dev/null +++ b/backend/api/serializers/_helpers.py @@ -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 diff --git a/backend/api/serializers/flavor.py b/backend/api/serializers/flavor.py new file mode 100644 index 0000000..c05f91d --- /dev/null +++ b/backend/api/serializers/flavor.py @@ -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 diff --git a/backend/api/serializers/rating.py b/backend/api/serializers/rating.py new file mode 100644 index 0000000..35a07a6 --- /dev/null +++ b/backend/api/serializers/rating.py @@ -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 diff --git a/backend/api/serializers/social.py b/backend/api/serializers/social.py new file mode 100644 index 0000000..0b8e6d5 --- /dev/null +++ b/backend/api/serializers/social.py @@ -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 diff --git a/backend/api/serializers/support.py b/backend/api/serializers/support.py new file mode 100644 index 0000000..a671438 --- /dev/null +++ b/backend/api/serializers/support.py @@ -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) diff --git a/backend/api/serializers/system.py b/backend/api/serializers/system.py new file mode 100644 index 0000000..52daf04 --- /dev/null +++ b/backend/api/serializers/system.py @@ -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", + ] diff --git a/backend/api/serializers/user.py b/backend/api/serializers/user.py new file mode 100644 index 0000000..74cbcc6 --- /dev/null +++ b/backend/api/serializers/user.py @@ -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()] diff --git a/backend/api/services/__init__.py b/backend/api/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/services/ip_logging.py b/backend/api/services/ip_logging.py new file mode 100644 index 0000000..fbdca20 --- /dev/null +++ b/backend/api/services/ip_logging.py @@ -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) diff --git a/backend/api/services/mentions.py b/backend/api/services/mentions.py new file mode 100644 index 0000000..3684e40 --- /dev/null +++ b/backend/api/services/mentions.py @@ -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, + ) diff --git a/backend/api/services/search.py b/backend/api/services/search.py new file mode 100644 index 0000000..2ce0166 --- /dev/null +++ b/backend/api/services/search.py @@ -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 diff --git a/backend/api/tasks.py b/backend/api/tasks.py new file mode 100644 index 0000000..80da034 --- /dev/null +++ b/backend/api/tasks.py @@ -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, + ) diff --git a/backend/api/tests.py b/backend/api/tests.py deleted file mode 100644 index 90358db..0000000 --- a/backend/api/tests.py +++ /dev/null @@ -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) diff --git a/backend/api/tests/__init__.py b/backend/api/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/tests/test_auth.py b/backend/api/tests/test_auth.py new file mode 100644 index 0000000..ad9f45c --- /dev/null +++ b/backend/api/tests/test_auth.py @@ -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) diff --git a/backend/api/tests/test_flavors.py b/backend/api/tests/test_flavors.py new file mode 100644 index 0000000..9586bdd --- /dev/null +++ b/backend/api/tests/test_flavors.py @@ -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) diff --git a/backend/api/tests/test_models.py b/backend/api/tests/test_models.py new file mode 100644 index 0000000..087dd1d --- /dev/null +++ b/backend/api/tests/test_models.py @@ -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) diff --git a/backend/api/urls.py b/backend/api/urls.py index 333674c..8a942a6 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -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)), ] diff --git a/backend/api/views.py b/backend/api/views.py deleted file mode 100644 index ae4317f..0000000 --- a/backend/api/views.py +++ /dev/null @@ -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[^/.]+)') - 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[^/.]+)', 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) diff --git a/backend/api/views/__init__.py b/backend/api/views/__init__.py new file mode 100644 index 0000000..0cf38a2 --- /dev/null +++ b/backend/api/views/__init__.py @@ -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", +] diff --git a/backend/api/views/_pagination.py b/backend/api/views/_pagination.py new file mode 100644 index 0000000..ce23acb --- /dev/null +++ b/backend/api/views/_pagination.py @@ -0,0 +1,5 @@ +from rest_framework.pagination import PageNumberPagination + + +class FeedPagination(PageNumberPagination): + page_size = 10 diff --git a/backend/api/views/admin.py b/backend/api/views/admin.py new file mode 100644 index 0000000..399f933 --- /dev/null +++ b/backend/api/views/admin.py @@ -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) diff --git a/backend/api/views/banner.py b/backend/api/views/banner.py new file mode 100644 index 0000000..d8d94db --- /dev/null +++ b/backend/api/views/banner.py @@ -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"}) diff --git a/backend/api/views/flavor.py b/backend/api/views/flavor.py new file mode 100644 index 0000000..879f742 --- /dev/null +++ b/backend/api/views/flavor.py @@ -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) diff --git a/backend/api/views/rating.py b/backend/api/views/rating.py new file mode 100644 index 0000000..62132bb --- /dev/null +++ b/backend/api/views/rating.py @@ -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() diff --git a/backend/api/views/support.py b/backend/api/views/support.py new file mode 100644 index 0000000..59876df --- /dev/null +++ b/backend/api/views/support.py @@ -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) diff --git a/backend/api/views/user.py b/backend/api/views/user.py new file mode 100644 index 0000000..c4f8c14 --- /dev/null +++ b/backend/api/views/user.py @@ -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[^/.]+)", + ) + 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[^/.]+)", + 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"}) diff --git a/backend/holy_backend/__init__.py b/backend/holy_backend/__init__.py index e69de29..f7a0c99 100644 --- a/backend/holy_backend/__init__.py +++ b/backend/holy_backend/__init__.py @@ -0,0 +1,3 @@ +from holy_backend.celery import app as celery_app + +__all__ = ("celery_app",) diff --git a/backend/holy_backend/asgi.py b/backend/holy_backend/asgi.py index bc52fbe..7da6dde 100644 --- a/backend/holy_backend/asgi.py +++ b/backend/holy_backend/asgi.py @@ -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() diff --git a/backend/holy_backend/celery.py b/backend/holy_backend/celery.py new file mode 100644 index 0000000..6ad75c8 --- /dev/null +++ b/backend/holy_backend/celery.py @@ -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() diff --git a/backend/holy_backend/settings.py b/backend/holy_backend/settings.py deleted file mode 100644 index bf87991..0000000 --- a/backend/holy_backend/settings.py +++ /dev/null @@ -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, -} diff --git a/backend/holy_backend/settings/__init__.py b/backend/holy_backend/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/holy_backend/settings/base.py b/backend/holy_backend/settings/base.py new file mode 100644 index 0000000..a215850 --- /dev/null +++ b/backend/holy_backend/settings/base.py @@ -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" diff --git a/backend/holy_backend/settings/dev.py b/backend/holy_backend/settings/dev.py new file mode 100644 index 0000000..03dcc93 --- /dev/null +++ b/backend/holy_backend/settings/dev.py @@ -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 diff --git a/backend/holy_backend/settings/prod.py b/backend/holy_backend/settings/prod.py new file mode 100644 index 0000000..cf69de2 --- /dev/null +++ b/backend/holy_backend/settings/prod.py @@ -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 diff --git a/backend/holy_backend/urls.py b/backend/holy_backend/urls.py index f114fad..d03275f 100644 --- a/backend/holy_backend/urls.py +++ b/backend/holy_backend/urls.py @@ -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/', 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/", serve, {"document_root": settings.MEDIA_ROOT}), ] diff --git a/backend/holy_backend/wsgi.py b/backend/holy_backend/wsgi.py index 4739488..8fec447 100644 --- a/backend/holy_backend/wsgi.py +++ b/backend/holy_backend/wsgi.py @@ -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() diff --git a/backend/manage.py b/backend/manage.py index 9689b6f..b8ed64e 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -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() diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..24d5a8a --- /dev/null +++ b/backend/pyproject.toml @@ -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" diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..85b0311 --- /dev/null +++ b/backend/requirements-dev.txt @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt index 5facd97..13984fb 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index badcb98..8a954f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..f8ceb57 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,5 @@ +dist +node_modules +public +*.lock +package-lock.json diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..3c2db7c --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,8 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "semi": true, + "arrowParens": "always" +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 5e6b472..f1c3480 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -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', + }, }, ]) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0d1c062..36b503e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,20 +24,74 @@ }, "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" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.9.tgz", + "integrity": "sha512-zd9c/Wdso6v1U7v6w3i/hbAr4K7NaSHImdpvmLt+Y9ea5BhilnIGNkfhOJ7FEIuPipAnE9tZeDOll05WDT0kgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.9.tgz", + "integrity": "sha512-r3ElRr7y8ucyN2KdICwGsmj19RoN13CLCa/pvGydghWK6ZzeKQ+TcDjVdtEZz2ElpndM5jXw//B9CEee0mWnVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -319,6 +373,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@catppuccin/palette": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@catppuccin/palette/-/palette-1.7.1.tgz", @@ -335,6 +402,146 @@ ], "license": "MIT" }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", + "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", @@ -1096,6 +1303,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1794,6 +2019,13 @@ } } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -2161,12 +2393,117 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tokenizer/token": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "license": "MIT" }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2212,6 +2549,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/colorthief": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/@types/colorthief/-/colorthief-2.6.1.tgz", @@ -2222,6 +2570,13 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2582,6 +2937,141 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.4.tgz", + "integrity": "sha512-EgFR7nlj5iTDYZYCvavjFokNYwr3c3ry0sFiCg+N7B233Nwp+NNx7eoF/XvMWDCKY71xXAG3kFkt97ZHBJVL8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "fflate": "^0.8.2", + "flatted": "^3.4.2", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.4" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -2634,6 +3124,17 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2657,6 +3158,26 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2726,6 +3247,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2838,6 +3369,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2996,6 +3537,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3011,6 +3573,20 @@ "uniq": "^1.0.0" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3028,6 +3604,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3044,6 +3627,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3053,6 +3646,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -3084,6 +3685,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -3111,6 +3725,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3262,6 +3883,22 @@ } } }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-plugin-react-hooks": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", @@ -3376,6 +4013,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3404,6 +4051,16 @@ "node": ">=0.8.x" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3443,6 +4100,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3511,9 +4175,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -3743,6 +4407,19 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -3848,6 +4525,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/iota-array": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz", @@ -3904,6 +4591,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3930,6 +4624,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", + "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4057,6 +4802,27 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4066,6 +4832,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -4087,6 +4860,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4100,6 +4883,16 @@ "node": "*" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4179,6 +4972,17 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4259,6 +5063,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4294,6 +5111,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/peek-readable": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", @@ -4365,6 +5189,60 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", + "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -4556,6 +5434,30 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -4650,6 +5552,19 @@ ], "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -4746,6 +5661,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/simple-swizzle": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", @@ -4761,6 +5683,21 @@ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", "license": "MIT" }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -4780,6 +5717,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -4789,6 +5740,19 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4850,6 +5814,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4867,6 +5855,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/token-types": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", @@ -4884,6 +5902,42 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -4955,6 +6009,16 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -5084,6 +6148,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -5093,6 +6247,54 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5109,6 +6311,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -5119,6 +6338,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 003ce43..d566e52 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6d83f9a..55263f0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,10 @@ -import React, { useState, useEffect, useMemo, Suspense, lazy } from 'react'; -import { Routes, Route, Link, useNavigate, useLocation } from 'react-router-dom'; -import { - ThemeProvider, - CssBaseline, - AppBar, - Toolbar, - Typography, - Button, +import React, { Suspense, lazy, useEffect, useState } from 'react'; +import { Routes, Route, Link, useNavigate } from 'react-router-dom'; +import { + AppBar, + Toolbar, + Typography, + Button, Box, IconButton, Avatar, @@ -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,424 +59,222 @@ 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([]); - 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 ( - - 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) => ( - - - - ), - endAdornment: ( - - {loading ? : null} - {params.InputProps.endAdornment} - - ), - }} - /> - )} - renderOption={(props, option) => { - const { key, ...optionProps } = props as any; - return ( - - - - {option.name} - {option.subtitle} - - - ); - }} - /> - - ); -}; - const App: React.FC = () => { - const { i18n, t } = useTranslation(); + const { t } = useTranslation(); const navigate = useNavigate(); - const [themeName, setThemeName] = useState((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); const [notifAnchorEl, setNotifAnchorEl] = useState(null); const [catAnchorEl, setCatAnchorEl] = useState(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([]); - 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)'); - 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); + api + .get('categories/') + .then((res) => { + const data = Array.isArray(res.data) ? res.data : res.data.results || []; + setCategories(data); + }) + .catch(() => setCategories([])); }, []); - 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]); + document.body.style.backgroundColor = muiTheme.palette.background.default; + document.body.style.color = muiTheme.palette.text.primary; + }, [muiTheme]); - 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 toggleAdminMode = () => { + const newVal = !adminMode; + setAdminMode(newVal); + localStorage.setItem('admin-mode', String(newVal)); }; - 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 = ( - - setDrawerOpen(false)} style={{ color: 'inherit', textDecoration: 'none' }}> - Holy Flavors Archive - - + + setDrawerOpen(false)} + style={{ color: 'inherit', textDecoration: 'none' }} + > + Holy Flavors Archive + + - setDrawerOpen(false)}> - - + setDrawerOpen(false)}> + + {user && ( - - setDrawerOpen(false)}> - - - + + setDrawerOpen(false)}> + + + )} setCatOpen(!catOpen)}> - - {catOpen ? : } + + {catOpen ? : } - - {categories.map(cat => ( - setDrawerOpen(false)} - sx={{ pl: 4 }} - > - - - ))} - + + {categories.map((cat) => ( + setDrawerOpen(false)} + sx={{ pl: 4 }} + > + + + ))} + - + {user ? ( - <> - - setDrawerOpen(false)}> - - - {user.username.charAt(0).toUpperCase()} - - - {user.username} - - - } - /> - - - - setDrawerOpen(false)}> - - - - - setDrawerOpen(false)}> - - - - - setDrawerOpen(false)}> - - - - - {user.is_superuser && ( - - - - - - )} - - {user.is_superuser && ( - - setDrawerOpen(false)}> - - - - )} - - {following.length > 0 && ( - <> - - {t('nav.following')} - {following.map(f => ( - - setDrawerOpen(false)}> - - - {!f.avatar && f.username.charAt(0).toUpperCase()} - - - - - - ))} - - )} - - - - - - - - ) : ( + <> - setDrawerOpen(false)}> - - + setDrawerOpen(false)} + > + + + {user.username.charAt(0).toUpperCase()} + + + {user.username} + + + } + /> + + + setDrawerOpen(false)}> + + + + + setDrawerOpen(false)}> + + + + + setDrawerOpen(false)}> + + + + + {user.is_superuser && ( + + + + + + )} + + {user.is_superuser && ( + + setDrawerOpen(false)} + > + + + + )} + + {following.length > 0 && ( + <> + + + {t('nav.following')} + + {following.map((f) => ( + + setDrawerOpen(false)} + > + + + {!f.avatar && f.username.charAt(0).toUpperCase()} + + + + + + ))} + + )} + + + + + + + + ) : ( + + setDrawerOpen(false)}> + + + )} @@ -486,241 +282,319 @@ const App: React.FC = () => { const notificationsMenu = ( <> - setNotifAnchorEl(e.currentTarget)} sx={{ ml: 1 }}> - - - - - setNotifAnchorEl(null)} - elevation={3} - sx={{ mt: 1 }} - PaperProps={{ sx: { width: 320, maxHeight: 400, borderRadius: 1 } }} + setNotifAnchorEl(e.currentTarget)} sx={{ ml: 1 }}> + + + + + setNotifAnchorEl(null)} + elevation={3} + sx={{ mt: 1 }} + PaperProps={{ sx: { width: 320, maxHeight: 400, borderRadius: 1 } }} + > + - - Notifications - {(user?.unread_notifications_count || 0) > 0 && ( - - )} - - {notifications.length === 0 ? ( - - No notifications yet - - ) : ( - 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 ( - - - {n.actor_username.charAt(0).toUpperCase()} - - - - {n.actor_username} { - 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') - } - - - {formatDate(n.created_at)} - - - - ); - }) - )} - + + Notifications + + {(user?.unread_notifications_count || 0) > 0 && ( + + )} + + {notifications.length === 0 ? ( + + + No notifications yet + + + ) : ( + notifications.map((n) => ( + handleNotificationClick(n)} + sx={{ + py: 1.5, + px: 2, + borderBottom: '1px solid', + borderColor: 'divider', + bgcolor: n.is_read ? 'transparent' : alpha(muiTheme.palette.primary.main, 0.05), + whiteSpace: 'normal', + display: 'flex', + gap: 2, + alignItems: 'flex-start', + }} + > + + {n.actor_username.charAt(0).toUpperCase()} + + + + {n.actor_username}{' '} + {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')} + + + {formatDate(n.created_at)} + + + + )) + )} + ); return ( - - - - alpha(theme.palette.background.paper, 0.8), - backdropFilter: 'blur(12px)', - color: 'text.primary' - }} - > - - - - Holy Flavors Archive - HFA - - - - - {user && ( - - )} - - setCatAnchorEl(null)} - elevation={3} - sx={{ mt: 1 }} - > - {categories.map(cat => ( - setCatAnchorEl(null)} - > - {t(`categories.${cat.slug}`, { defaultValue: cat.name })} - - ))} - - - - - - - {!loadingUser && ( - <> - {user && notificationsMenu} - - {user ? ( - - setAnchorEl(e.currentTarget)}> - - {user.username.charAt(0).toUpperCase()} - - - setAnchorEl(null)} - elevation={3} - sx={{ mt: 1 }} - > - setAnchorEl(null)}>{t('nav.profile')} - setAnchorEl(null)}>{t('nav.dashboard')} - setAnchorEl(null)}>{t('nav.settings')} - setAnchorEl(null)}>{t('support.title')} - {user.is_superuser && ( - - {t('admin.adminMode')}: {adminMode ? "ON" : "OFF"} - - )} - {user.is_superuser && ( - setAnchorEl(null)} sx={{ fontWeight: 'bold', color: 'primary.main' }}>Admin Panel - )} - {t('nav.logout')} - - ) : ( - - - - )} - - )} - - {isMobile && ( - setDrawerOpen(true)} - sx={{ ml: 1 }} - > - - - )} - - - - - setDrawerOpen(false)} + + alpha(th.palette.background.paper, 0.8), + backdropFilter: 'blur(12px)', + color: 'text.primary', + }} + > + + - {drawer} - + + + Holy Flavors Archive + + + HFA + + + - - }> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - 404 - Page Not Found} /> - - + + {user && ( + + )} + + setCatAnchorEl(null)} + elevation={3} + sx={{ mt: 1 }} + > + {categories.map((cat) => ( + setCatAnchorEl(null)} + > + {t(`categories.${cat.slug}`, { defaultValue: cat.name })} + + ))} + -