diff --git a/.gitignore b/.gitignore index a495895..eb69ea1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # Dependencies node_modules/ -backend/venv/ .venv/ env/ venv/ @@ -9,21 +8,18 @@ ENV/ # Databases *.sqlite3 db.sqlite3 -backend/db.sqlite3-shm -backend/db.sqlite3-wal +data/db.sqlite3-shm +data/db.sqlite3-wal # Python cache __pycache__/ *.py[cod] *$py.class -backend/**/__pycache__/ -# Django specific -backend/staticfiles/ -backend/media/ -backend/django_cache/ -backend/backups/ -backend/.seeded +# Data specific +data/media/ +data/backups/ +data/.seeded # React/Vite build dist/ diff --git a/README.md b/README.md index a48add2..aed4284 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,14 @@ The easiest way to get the full stack (Rust backend + Frontend) running behind a ``` The stack brings up two containers: `backend-rs` (`holy-rust:8001`, the Rust API) and `frontend` (Nginx — serves the SPA, proxies `/api` to the backend, serves `/media` from the shared mount). `docker-compose.override.yml` adds the Traefik production overlay. - The Rust backend reads the existing `backend/db.sqlite3` directly (no migration step). Seeding (flavor catalog, legacy data, banners) runs as background jobs — trigger them from the admin Jobs tab or let the scheduler run them. + The Rust backend reads the existing `data/db.sqlite3` directly (no migration step). Seeding (flavor catalog, legacy data, banners) runs as background jobs — trigger them from the admin Jobs tab or let the scheduler run them. ### Option 2: Local Manual Setup (Development) #### Backend ```bash cd backend-rs -cp .env.example .env # set SECRET_KEY; point DATABASE_URL at ../backend/db.sqlite3 +cp .env.example .env # set SECRET_KEY; point DATABASE_URL at ../data/db.sqlite3 cargo run # listens on 0.0.0.0:8001 ``` @@ -54,7 +54,7 @@ npm install npm run dev ``` -> The Rust backend uses the existing SQLite database under `backend/db.sqlite3`. The `backend/` directory is retained as the data store (db, `media/`, `banners/`) and historical reference for the retired Django implementation — it is no longer run. +> The Rust backend uses the existing SQLite database under `data/db.sqlite3`. The `data/` directory is retained as the data store (db, `media/`, `banners/`, `backups/`). --- @@ -96,13 +96,13 @@ Run by the scheduler on their interval, or triggered from the admin Jobs tab (`P - `sync_flavors`: Syncs the latest products from the official Holy Energy (Shopify) catalog. - `cleanup_duplicates`: Merges duplicate entries and maintains rating integrity. - `seed_legacy`: Loads retired flavors from `legacy/*.json`. -- `seed_banners`: Updates procedurally generated banner configurations from `backend/banners/*.json`. -- `backup_db`: Creates a consistent SQLite + media snapshot in `backend/backups/`. +- `seed_banners`: Updates procedurally generated banner configurations from `data/banners/*.json`. +- `backup_db`: Creates a consistent SQLite + media snapshot in `data/backups/`. #### Categories `sync_flavors` derives a flavor's category from the Shopify `product_type` (e.g. `42 - Syrup Bottle` → **Syrup**), stripping the numeric prefix and form-factor word. Any new drink line Holy ships **auto-creates** its category on the next sync. Packs, shakers, merch, stickers and other non-drink types funnel to **Packs and other**. -`sync_flavors` only assigns a category when it **creates** a row — existing flavors keep their original category. To re-shelve already-stored rows, the retired Django app's `recategorize_flavors` management command remains available under `backend/` for occasional manual use (it is not part of the Rust job registry). +`sync_flavors` only assigns a category when it **creates** a row — existing flavors keep their original category. To re-shelve already-stored rows, you can run manual SQL updates directly against the SQLite database. --- @@ -112,7 +112,7 @@ Run by the scheduler on their interval, or triggered from the admin Jobs tab (`P ```bash bash scripts/backup.sh ``` -Creates a `.tar.gz` (DB + media) in `backend/backups/`. WAL-safe: uses `sqlite3 .backup` when available, otherwise copies the DB plus its `-wal`/`-shm` sidecars. The backend also runs `backup_db` on its own schedule. +Creates a `.tar.gz` (DB + media) in `data/backups/`. WAL-safe: uses `sqlite3 .backup` when available, otherwise copies the DB plus its `-wal`/`-shm` sidecars. The backend also runs `backup_db` on its own schedule. **Automating with Cron** — nightly at 2:00 AM: ```bash @@ -124,8 +124,8 @@ Creates a `.tar.gz` (DB + media) in `backend/backups/`. WAL-safe: uses `sqlite3 2. Stop the containers: `docker compose down` 3. Replace the current files: ```bash - cp extracted_folder/db.sqlite3 backend/db.sqlite3 - cp -r extracted_folder/media/* backend/media/ + cp extracted_folder/db.sqlite3 data/db.sqlite3 + cp -r extracted_folder/media/* data/media/ ``` 4. Restart: `docker compose up -d` diff --git a/backend-rs/.env.example b/backend-rs/.env.example index 4519cb1..e7876ad 100644 --- a/backend-rs/.env.example +++ b/backend-rs/.env.example @@ -1,6 +1,6 @@ # Path to the sqlite database. Relative paths resolve from the process CWD; # absolute is safest. Use mode=rw in prod so a wrong path fails loudly. -DATABASE_URL=sqlite://../backend/db.sqlite3?mode=rwc +DATABASE_URL=sqlite://../data/db.sqlite3?mode=rwc # Signs the SimpleJWT-compatible tokens. Keep this CONSTANT across deploys and # restarts — changing it invalidates every issued access/refresh token. (Use the @@ -14,7 +14,7 @@ BIND_ADDR=0.0.0.0:8001 MEDIA_URL=media/ # Filesystem root for uploaded media (avatars). -MEDIA_ROOT=../backend/media +MEDIA_ROOT=../data/media # Base URL of the frontend, used to build email links (verify-email, reset). FRONTEND_URL=http://localhost:5173 diff --git a/backend-rs/README.md b/backend-rs/README.md index 32ac636..86d123c 100644 --- a/backend-rs/README.md +++ b/backend-rs/README.md @@ -1,17 +1,13 @@ # holy_backend_rs — Rust backend (strangler migration) -Incremental Rust rewrite of the Django backend. Both backends run side by side -and **share the same SQLite file**; an nginx proxy routes already-ported -endpoint groups to Rust and everything else to Django. Endpoints move group by -group until Django can be retired. +Rust backend for the Holy Flavor Rater. The original Django backend has been fully retired and deleted. - **Web**: Axum 0.8 + utoipa (OpenAPI) + Swagger UI -- **DB**: SeaORM over the existing SQLite (`backend/db.sqlite3`), schema unchanged +- **DB**: SeaORM over the SQLite database (`data/db.sqlite3`), schema unchanged - **Auth**: SimpleJWT-compatible — verifies *and issues* HS256 tokens signed - with the same `SECRET_KEY` as Django (`Authorization: Bearer` or `access_token` - cookie). Login/refresh/verify/logout are ported; tokens are interchangeable - with Django (verified both directions). -- **Jobs**: tokio-cron-scheduler in-process (not yet wired; replaces Celery+beat) + with the `SECRET_KEY` (`Authorization: Bearer` or `access_token` + cookie). Login/refresh/verify/logout are ported. +- **Jobs**: tokio-cron-scheduler in-process (replaces Celery+beat) ## Ported so far (read slice) @@ -200,7 +196,7 @@ The Rust backend is the production API; Django/Celery/Redis were retired after the strangler migration completed. `docker compose` (`docker-compose.yml` + `docker-compose.override.yml`) runs two services: `backend-rs` (`holy-rust:8001`) and `frontend` (nginx). nginx serves the SPA, proxies `/api` to the Rust backend, -and serves `/media` from the shared `./backend/media` bind mount; see +and serves `/media` from the shared `./data/media` bind mount; see `frontend/nginx.conf`. `X-Forwarded-Proto`/`Host` are required: absolute URLs (image links, pagination @@ -212,8 +208,8 @@ the peer address otherwise). Run a single `backend-rs` instance with `ENABLE_SCHEDULER=true` (`RUST_ENABLE_SCHEDULER` in `.env`); any additional instances must leave it off. -The canonical data lives in host bind mounts — `backend/db.sqlite3` (WAL on) and -`backend/media/` — and survives image rebuilds. Keep them. +The canonical data lives in host bind mounts — `data/db.sqlite3` (WAL on) and +`data/media/` — and survives image rebuilds. Keep them. ## Caveats / parity TODO diff --git a/backend-rs/src/config.rs b/backend-rs/src/config.rs index b83c522..d1b42ae 100644 --- a/backend-rs/src/config.rs +++ b/backend-rs/src/config.rs @@ -23,15 +23,14 @@ pub struct Config { /// can't use a wildcard, so this is an explicit allow-list. pub cors_allowed_origins: Vec, pub email: EmailConfig, - /// Directory of banner JSON configs (Django's `backend/banners/`). + /// Directory of banner JSON configs (`data/banners/`). pub banners_dir: String, /// Directory of legacy flavor JSON dumps (repo-root `legacy/`). pub legacy_dir: String, - /// Output directory for database/media backups (Django's `backend/backups/`). + /// Output directory for database/media backups (`data/backups/`). pub backup_dir: String, - /// Whether the in-process job scheduler runs. MUST be false while Django's - /// Celery `beat` still owns scheduling — otherwise jobs fire twice against - /// the shared `api_job` table. Flip to true only after disabling Django beat. + /// Whether the in-process job scheduler runs. Production (docker compose) + /// sets this to true on exactly one instance to own job scheduling. pub enable_scheduler: bool, } @@ -76,12 +75,12 @@ impl Config { // `mode=rw` (not `rwc`): the shared DB is created/owned by Django // and must already exist. `rwc` would silently create an empty DB // if the path were wrong, masking a misconfiguration. - .unwrap_or_else(|_| "sqlite://../backend/db.sqlite3?mode=rw".to_string()), + .unwrap_or_else(|_| "sqlite://../data/db.sqlite3?mode=rw".to_string()), secret_key: env::var("SECRET_KEY") .map_err(|_| anyhow::anyhow!("SECRET_KEY must be set (match Django's)"))?, bind_addr: env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:8001".to_string()), media_url: env::var("MEDIA_URL").unwrap_or_else(|_| "media/".to_string()), - media_root: env::var("MEDIA_ROOT").unwrap_or_else(|_| "../backend/media".to_string()), + media_root: env::var("MEDIA_ROOT").unwrap_or_else(|_| "../data/media".to_string()), jwt_cookie_secure: env_bool("JWT_AUTH_COOKIE_SECURE", false), frontend_url: env::var("FRONTEND_URL") .unwrap_or_else(|_| "http://localhost:5173".to_string()) @@ -122,9 +121,9 @@ impl Config { .unwrap_or_else(|| "noreply@localhost".to_string()), }, banners_dir: env::var("BANNERS_DIR") - .unwrap_or_else(|_| "../backend/banners".to_string()), + .unwrap_or_else(|_| "../data/banners".to_string()), legacy_dir: env::var("LEGACY_DIR").unwrap_or_else(|_| "../legacy".to_string()), - backup_dir: env::var("BACKUP_DIR").unwrap_or_else(|_| "../backend/backups".to_string()), + backup_dir: env::var("BACKUP_DIR").unwrap_or_else(|_| "../data/backups".to_string()), // Default OFF so ad-hoc `cargo run` instances don't run jobs. // Production (docker compose) sets ENABLE_SCHEDULER=true on the one // instance that should own `api_job` scheduling. diff --git a/backend/.dockerignore b/backend/.dockerignore deleted file mode 100644 index 297ee42..0000000 --- a/backend/.dockerignore +++ /dev/null @@ -1,35 +0,0 @@ -# Virtual environments -venv/ -.venv/ -env/ -ENV/ - -# Python cache -__pycache__/ -*.pyc -*.py[cod] - -# Tooling caches -.mypy_cache/ -.pytest_cache/ -.ruff_cache/ - -# Database -db.sqlite3* - -# Runtime data (bind-mounted at runtime, no need to bake into image) -media/ -backups/ -staticfiles/ -django_cache/ -.seeded - -# Secrets / logs -.env -.env.* -!.env.example -*.log - -# IDEs -.idea/ -.vscode/ diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index f466160..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,30 +0,0 @@ -# Use an official Python runtime as a parent image -FROM python:3.14-slim - -# Set environment variables -ENV PYTHONDONTWRITEBYTECODE 1 -ENV PYTHONUNBUFFERED 1 - -# Set the working directory in the container -WORKDIR /app - -# Install dependencies -COPY requirements.txt . -RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* -RUN pip install --no-cache-dir -r requirements.txt - -# Non-root user for celery workers/beat. UID matches typical host (1000) so -# bind-mounted volumes (./backend, ./legacy, media/) stay writable. Override -# by rebuilding with --build-arg APP_UID= if your host differs. -ARG APP_UID=1000 -ARG APP_GID=1000 -RUN groupadd -g ${APP_GID} app && useradd -u ${APP_UID} -g app -m -s /bin/bash app - -# Copy the rest of the backend code -COPY . . - -# Expose the port the app runs on -EXPOSE 8000 - -# Run the entrypoint script -ENTRYPOINT ["./entrypoint.sh"] diff --git a/backend/api/__init__.py b/backend/api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/api/admin.py b/backend/api/admin.py deleted file mode 100644 index 4cc6ce6..0000000 --- a/backend/api/admin.py +++ /dev/null @@ -1,44 +0,0 @@ -from django import forms -from django.contrib import admin -from django.contrib.auth.admin import UserAdmin - -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",)}),) - - -class FlavorAdminForm(forms.ModelForm): - class Meta: - model = Flavor - exclude: list[str] = [] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - instance = kwargs.get("instance") - paths = list(instance.local_image_paths or []) if instance else [] - if paths: - choices = [("", "— first available —")] + [(p, p.split("/")[-1]) for p in paths] - self.fields["main_image_path"] = forms.ChoiceField( - choices=choices, - required=False, - label="Main image", - help_text="Pick which downloaded image is used as the primary.", - ) - - -class FlavorAdmin(admin.ModelAdmin): - form = FlavorAdminForm - list_display = ("name", "category", "is_available", "is_legacy", "external_id") - list_filter = ("category", "is_available", "is_legacy") - search_fields = ("name",) - readonly_fields = ("local_image_paths",) - - -admin.site.register(User, CustomUserAdmin) -admin.site.register(Category) -admin.site.register(Flavor, FlavorAdmin) -admin.site.register(Rating) -admin.site.register(Banner) diff --git a/backend/api/apps.py b/backend/api/apps.py deleted file mode 100644 index 14b89a8..0000000 --- a/backend/api/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class ApiConfig(AppConfig): - name = "api" diff --git a/backend/api/email_backend.py b/backend/api/email_backend.py deleted file mode 100644 index 45ef04a..0000000 --- a/backend/api/email_backend.py +++ /dev/null @@ -1,20 +0,0 @@ -import ssl - -from django.conf import settings -from django.core.mail.backends.smtp import EmailBackend - - -class InsecureEmailBackend(EmailBackend): - """ - SMTP email backend that allows skipping SSL certificate verification. - 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): - # The most robust way to bypass verification for self-signed certs - context = ssl._create_unverified_context() - return context - return super().ssl_context diff --git a/backend/api/management/commands/backup_db.py b/backend/api/management/commands/backup_db.py deleted file mode 100644 index b8575e5..0000000 --- a/backend/api/management/commands/backup_db.py +++ /dev/null @@ -1,96 +0,0 @@ -import os -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." - - 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" - ) - - def handle(self, *args, **options): - 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") - os.makedirs(backup_dir, exist_ok=True) - - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - - # 1. Database Backup - if options["output"]: - db_backup_path = options["output"] - else: - 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) - with dst: - src.backup(dst) - dst.close() - src.close() - self.stdout.write(self.style.SUCCESS(f"DB backup created: {db_backup_path}")) - except Exception as e: - self.stdout.write(self.style.ERROR(f"DB backup failed: {e}")) - return - - # 2. Full Archive (Optional) - 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 - tar.add(db_backup_path, arcname="db.sqlite3") - # Add media - 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"]: - os.remove(db_backup_path) - - 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"] - 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) - if os.path.isfile(file_path): - file_time = datetime.fromtimestamp(os.path.getmtime(file_path)) - if file_time < cutoff: - try: - os.remove(file_path) - 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}") - ) diff --git a/backend/api/management/commands/cleanup_duplicates.py b/backend/api/management/commands/cleanup_duplicates.py deleted file mode 100644 index 4bc41ca..0000000 --- a/backend/api/management/commands/cleanup_duplicates.py +++ /dev/null @@ -1,73 +0,0 @@ -import re - -from django.core.management.base import BaseCommand - -from api.models import Category, Flavor -from api.services.flavor import merge_flavors - -SUFFIXES = [ - r"\s+Energy$", - r"\s+Hydration$", - r"\s+Iced\s+Tea$", - r"\s+\(Legacy\)$", -] - - -class Command(BaseCommand): - help = "Identifies and merges duplicate flavors (normalized name and category)" - - def add_arguments(self, parser): - parser.add_argument( - "--dry-run", - action="store_true", - help="Report merges without performing them.", - ) - parser.add_argument( - "--no-normalize", - action="store_true", - help="Match exact names only; do not strip suffixes like ' Energy'.", - ) - - def normalize_name(self, name: str, strip_suffixes: bool = True) -> str: - """Lowercase, trim, and (optionally) strip common product suffixes.""" - norm = name.strip() - if strip_suffixes: - for suffix in SUFFIXES: - norm = re.sub(suffix, "", norm, flags=re.IGNORECASE) - return norm.strip().lower() - - def handle(self, *args, **options): - dry_run: bool = options.get("dry_run", False) - strip_suffixes: bool = not options.get("no_normalize", False) - - if dry_run: - self.stdout.write(self.style.WARNING("DRY RUN — no changes will be made.")) - - total_merged = 0 - - for category in Category.objects.all(): - grouped: dict[str, list[Flavor]] = {} - for f in Flavor.objects.filter(category=category): - grouped.setdefault(self.normalize_name(f.name, strip_suffixes), []).append(f) - - for flavor_set in grouped.values(): - if len(flavor_set) <= 1: - continue - - # Keep the catalog row (has external_id) and the oldest on ties. - flavor_set.sort(key=lambda x: (x.external_id is None, x.created_at)) - keep, others = flavor_set[0], flavor_set[1:] - - names = ", ".join(f"'{o.name}' (ID {o.id})" for o in others) - self.stdout.write( - f"{'[dry-run] would merge' if dry_run else 'Merging'} {names} " - f"into '{keep.name}' (Category: {category.name}, ID: {keep.id})" - ) - - if not dry_run: - for other in others: - merge_flavors(keep, other) - total_merged += len(others) - - verb = "Would merge" if dry_run else "Finished cleaning up" - self.stdout.write(self.style.SUCCESS(f"{verb} {total_merged} duplicates.")) diff --git a/backend/api/management/commands/gc_orphan_images.py b/backend/api/management/commands/gc_orphan_images.py deleted file mode 100644 index 59e33a8..0000000 --- a/backend/api/management/commands/gc_orphan_images.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Garbage collect orphan flavor image files. - -Walks MEDIA_ROOT/flavors/ and removes directories whose paths are not referenced -by any Flavor.local_image_paths or Flavor.main_image_path. Use --dry-run to -preview without deleting. - -Files referenced by the legacy Flavor.image ImageField are also preserved (for -backwards compatibility) until that field is dropped. -""" - -from __future__ import annotations - -import os -import shutil -from pathlib import Path - -from django.conf import settings -from django.core.management.base import BaseCommand - -from api.models import Flavor - - -class Command(BaseCommand): - help = "Remove orphan flavor image files / directories not referenced by any Flavor." - - def add_arguments(self, parser): - parser.add_argument( - "--dry-run", - action="store_true", - help="Print what would be deleted but don't touch the filesystem.", - ) - - def handle(self, *args, **options): - dry_run: bool = options["dry_run"] - flavors_root = Path(settings.MEDIA_ROOT) / "flavors" - if not flavors_root.exists(): - self.stdout.write(self.style.WARNING(f"No flavors dir at {flavors_root}")) - return - - # Collect every relative path/directory that should be kept. - referenced_files: set[str] = set() - referenced_dirs: set[str] = set() - legacy_image_files: set[str] = set() - - for flavor in Flavor.objects.all().only("local_image_paths", "main_image_path", "image"): - paths = list(flavor.local_image_paths or []) - if flavor.main_image_path: - paths.append(flavor.main_image_path) - for rel in paths: - referenced_files.add(rel) - referenced_dirs.add(os.path.dirname(rel)) - if flavor.image: - legacy_image_files.add(flavor.image.name) - - # Walk the flavors tree and decide what's unreferenced. - deleted_dirs: list[str] = [] - deleted_files: list[str] = [] - kept = 0 - - for entry in sorted(flavors_root.iterdir()): - rel_dir = f"flavors/{entry.name}" - if entry.is_dir(): - if rel_dir in referenced_dirs: - # Drop only orphan files inside the referenced dir. - for f in entry.iterdir(): - rel_file = f"{rel_dir}/{f.name}" - if f.is_file() and rel_file not in referenced_files: - deleted_files.append(rel_file) - if not dry_run: - f.unlink() - else: - kept += 1 - else: - deleted_dirs.append(rel_dir) - if not dry_run: - shutil.rmtree(entry) - elif entry.is_file(): - rel_file = f"flavors/{entry.name}" - if rel_file in legacy_image_files or rel_file in referenced_files: - kept += 1 - else: - deleted_files.append(rel_file) - if not dry_run: - entry.unlink() - - verb = "Would delete" if dry_run else "Deleted" - self.stdout.write( - self.style.SUCCESS(f"{verb}: {len(deleted_dirs)} dirs, {len(deleted_files)} files") - ) - for path in deleted_dirs: - self.stdout.write(f" dir {path}") - for path in deleted_files: - self.stdout.write(f" file {path}") - self.stdout.write(f"Kept: {kept} entries") diff --git a/backend/api/management/commands/recategorize_flavors.py b/backend/api/management/commands/recategorize_flavors.py deleted file mode 100644 index 6fea07d..0000000 --- a/backend/api/management/commands/recategorize_flavors.py +++ /dev/null @@ -1,143 +0,0 @@ -import requests -from django.core.management.base import BaseCommand -from django.db import transaction - -from api.management.commands.sync_flavors import Command as SyncCommand -from api.models import Category, Flavor -from api.services.flavor import merge_flavors - -_CATALOG_URL = "https://weareholy.com/products.json?limit=250" -_PACKS_SLUG = "packs-and-other" -_PACKS_NAME = "Packs and other" - - -class Command(BaseCommand): - help = ( - "Re-evaluate the category of every already-synced flavor against the current " - "sync_flavors logic and move mis-shelved rows. Dry-run unless --apply is given. " - "Existing flavors keep their creation-time category during normal sync, so this " - "is the way to retro-apply categorization changes to rows that already exist." - ) - - def add_arguments(self, parser): - parser.add_argument( - "--apply", - action="store_true", - help="Persist changes. Without this flag the command only reports what would change.", - ) - parser.add_argument( - "--merge-collisions", - action="store_true", - help=( - "If the target category already holds a flavor with the same name, merge " - "this row into it (preserves ratings) instead of skipping. Implies a write; " - "only acts under --apply." - ), - ) - - def fetch_products(self) -> list[dict]: - response = requests.get(_CATALOG_URL, headers={"User-Agent": "Mozilla/5.0"}, timeout=15) - response.raise_for_status() - return response.json().get("products", []) - - def handle(self, *args, **options): - apply = options["apply"] - merge_collisions = options["merge_collisions"] - - try: - products = self.fetch_products() - except Exception as e: - self.stdout.write(self.style.ERROR(f"Failed to fetch catalog: {e}")) - return - - by_id = {p["id"]: p for p in products if p.get("id") is not None} - self.stdout.write(f"Fetched {len(by_id)} catalog products.") - - sync = SyncCommand() - moved = merged = collisions = unresolved = not_in_catalog = unchanged = 0 - - # Everything mutating runs in one transaction. resolve_category auto-creates - # categories as a side effect, so dry-run rolls the whole thing back to stay pure. - with transaction.atomic(): - packs_category = Category.objects.get_or_create( - slug=_PACKS_SLUG, defaults={"name": _PACKS_NAME} - )[0] - moved, merged, collisions, unresolved, not_in_catalog, unchanged = self._process( - by_id, sync, packs_category, apply, merge_collisions - ) - if not apply: - transaction.set_rollback(True) - - verb = "Applied" if apply else "Would change (dry-run)" - self.stdout.write( - self.style.SUCCESS( - f"{verb}: moved={moved}, merged={merged}, collisions={collisions}, " - f"unresolved={unresolved}, unchanged={unchanged}, not_in_catalog={not_in_catalog}" - ) - ) - if not apply and (moved or collisions): - self.stdout.write("Re-run with --apply to persist.") - - def _process(self, by_id, sync, packs_category, apply, merge_collisions): - moved = merged = collisions = unresolved = not_in_catalog = unchanged = 0 - - for flavor in Flavor.objects.filter(external_id__isnull=False).select_related("category"): - product = by_id.get(flavor.external_id) - if product is None: - not_in_catalog += 1 - continue - - title = product.get("title", "").strip() - tags = product.get("tags", []) - if isinstance(tags, str): - tags = [t.strip() for t in tags.split(",")] - tags_lower = [t.lower() for t in tags] - - target = sync.resolve_category(product, title.lower(), tags_lower, packs_category) - - if target is None: - unresolved += 1 - self.stdout.write( - self.style.WARNING( - f" ? {flavor.name!r} [{flavor.category.slug}]: resolver yields no " - f"category (product_type={product.get('product_type')!r}) — left as-is" - ) - ) - continue - - if target.id == flavor.category_id: - unchanged += 1 - continue - - conflict = ( - Flavor.objects.filter(name=flavor.name, category=target) - .exclude(pk=flavor.pk) - .first() - ) - if conflict is not None: - collisions += 1 - if merge_collisions: - self.stdout.write( - f" MERGE {flavor.name!r}: {flavor.category.slug} -> {target.slug} " - f"(into existing row #{conflict.pk})" - ) - if apply: - merge_flavors(conflict, flavor) - merged += 1 - else: - self.stdout.write( - self.style.WARNING( - f" SKIP {flavor.name!r}: target {target.slug} already has a flavor " - f"named {flavor.name!r} (#{conflict.pk}). Re-run with " - f"--merge-collisions to fold them." - ) - ) - continue - - self.stdout.write(f" MOVE {flavor.name!r}: {flavor.category.slug} -> {target.slug}") - if apply: - flavor.category = target - flavor.save(update_fields=["category"]) - moved += 1 - - return moved, merged, collisions, unresolved, not_in_catalog, unchanged diff --git a/backend/api/management/commands/seed_banners.py b/backend/api/management/commands/seed_banners.py deleted file mode 100644 index 4368bad..0000000 --- a/backend/api/management/commands/seed_banners.py +++ /dev/null @@ -1,75 +0,0 @@ -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/" - - 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") - - 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"): - filepath = os.path.join(banners_dir, filename) - try: - with open(filepath) as f: - data = json.load(f) - - 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", {}), - }, - ) - - 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", []) - - 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}" - ) - ) - else: - 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}") - ) diff --git a/backend/api/management/commands/seed_legacy_flavors.py b/backend/api/management/commands/seed_legacy_flavors.py deleted file mode 100644 index df224f4..0000000 --- a/backend/api/management/commands/seed_legacy_flavors.py +++ /dev/null @@ -1,165 +0,0 @@ -import hashlib -import json -import os -from urllib.parse import urlparse - -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 = "Adds legacy flavors from JSON files in the legacy/ folder" - - def download_image(self, url, flavor_name): - """Download legacy image into flavors/legacy_/00_.. Returns rel path.""" - if not url: - return None - try: - safe_name = slugify(flavor_name) - path = urlparse(url).path - ext = path.rsplit(".", 1)[-1].lower() if "." in path else "" - if ext not in ("jpg", "jpeg", "png", "webp", "gif"): - ext = "png" - url_hash = hashlib.sha1(path.encode("utf-8")).hexdigest()[:10] - rel_path = os.path.join("flavors", f"legacy_{safe_name}", f"00_{url_hash}.{ext}") - abs_path = os.path.join(settings.MEDIA_ROOT, rel_path) - if os.path.exists(abs_path) and os.path.getsize(abs_path) > 0: - return rel_path - os.makedirs(os.path.dirname(abs_path), exist_ok=True) - response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10) - response.raise_for_status() - with open(abs_path, "wb") as f: - f.write(response.content) - return rel_path - except Exception as e: - self.stdout.write( - self.style.WARNING(f"Failed to download image for {flavor_name}: {e}") - ) - return None - - def handle(self, *args, **options): - # We no longer delete everything to preserve user data like ratings - # 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], - } - - 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") - - if not os.path.exists(legacy_dir): - 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")] - 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"] - else: - file_cat = None - - 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") - - 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"] - elif "iced tea" in name.lower() or "eistee" in name.lower(): - 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"] - else: - 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) - 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 - - if image_url: - rel_path = self.download_image(image_url, name) - if rel_path: - flavor.local_image_paths = [rel_path] - if ( - flavor.main_image_path - and flavor.main_image_path not in flavor.local_image_paths - ): - flavor.main_image_path = None - self.stdout.write(f" -> Image for: {name}") - - flavor.image_url = image_url - flavor.image_urls = [image_url] if image_url else [] - flavor.save() - processed_count += 1 - - 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 deleted file mode 100644 index 69cfb6b..0000000 --- a/backend/api/management/commands/sync_flavors.py +++ /dev/null @@ -1,402 +0,0 @@ -import hashlib -import os -import re -from concurrent.futures import ThreadPoolExecutor -from urllib.parse import urlparse - -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 - -_DOWNLOAD_WORKERS = 8 - -# Shopify product_type looks like "42 - Syrup Bottle" / "01 - Energy Bundle". -# Strip the numeric prefix, then the trailing form-factor word, to get the -# drink family ("Syrup", "Energy", "Iced Tea") that becomes a Category. -_PRODUCT_TYPE_PREFIX = re.compile(r"^\s*\d+\s*[-–—]\s*") - -# Form-factor words are Holy's internal SKU taxonomy, NOT a pack signal: -# "Energy Bundle" is a single can, "Caipirinha Crab" is its title. Pack -# detection stays title-driven (see _is_pack). These only get stripped to -# reveal the family. -_FORM_WORDS = frozenset( - {"bundle", "sachet", "sachetbox", "bottle", "box", "pack", "set", "sachets"} -) - -# When the product_type family itself is one of these, it's not a drink line — -# funnel to "Packs and other" instead of minting a category for it. -_NON_FLAVOR_TOKENS = frozenset( - { - "value", - "shaker", - "merch", - "sticker", - "packaging", - "material", - "other", - "items", - "mixed", - "collector", - "collector's", - "free", - "products", - "naturalrabatt", - "sample", - "samplebox", - } -) - -_PACKS_SLUG = "packs-and-other" -_PACKS_NAME = "Packs and other" - -_PACK_TITLE_KEYWORDS = ( - "bundle", - "set", - "box", - "probe", - "sample", - "taster", - "starter", - "collection", - "probier", -) - -# Accessories (cleaning brushes, spare parts) inherit a drink form-factor in -# their product_type ("Reinigungsbürste – Syrup Bottle" -> "42 - Syrup Bottle"), -# so family resolution would mis-shelve them as that drink. The title is the -# only signal. Funnel to "Packs and other" like shakers. -_ACCESSORY_TITLE_KEYWORDS = ( - "bürste", - "reinigung", - "ersatzteil", - "zubehör", -) - -# Fallback only — product_type covers these, but some rows lack one. -_TAG_FAMILY = ( - (("holy energy", "energy"), "Energy"), - (("holy hydration", "hydration"), "Hydration"), - (("holy iced tea", "iced tea"), "Iced Tea"), - (("milkshake",), "Milkshake"), -) - - -def _family_from_product_type(product_type: str) -> str | None: - """Return the drink family from a Shopify product_type, or None. - - "42 - Syrup Bottle" -> "Syrup"; "Value Pack" / "Shaker" -> None (non-flavor). - """ - if not product_type: - return None - stripped = _PRODUCT_TYPE_PREFIX.sub("", product_type).strip() - if not stripped: - return None - - tokens = stripped.split() - if any(tok.lower() in _NON_FLAVOR_TOKENS for tok in tokens): - return None - - while tokens and tokens[-1].lower() in _FORM_WORDS: - tokens.pop() - if not tokens: - return None - return " ".join(tokens) - - -class Command(BaseCommand): - 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) - return cleantext.strip() - - def download_image_to_path(self, url, rel_path): - """Download `url` to MEDIA_ROOT/rel_path. Skip if file already exists. Returns rel_path or None.""" - if not url: - return None - abs_path = os.path.join(settings.MEDIA_ROOT, rel_path) - if os.path.exists(abs_path) and os.path.getsize(abs_path) > 0: - return rel_path - try: - os.makedirs(os.path.dirname(abs_path), exist_ok=True) - response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10) - response.raise_for_status() - with open(abs_path, "wb") as f: - f.write(response.content) - return rel_path - except Exception as e: - self.stdout.write(self.style.WARNING(f"Failed to download {url}: {e}")) - return None - - @staticmethod - def _ext_for(url): - path = urlparse(url).path - ext = path.rsplit(".", 1)[-1].lower() if "." in path else "" - return ext if ext in ("jpg", "jpeg", "png", "webp", "gif") else "png" - - @staticmethod - def _hash_for(url): - path = urlparse(url).path - return hashlib.sha1(path.encode("utf-8")).hexdigest()[:10] - - def download_gallery(self, image_urls, dir_slug): - """Download every URL into flavors//_.. Returns list of rel paths. - - Downloads run in parallel; ordering of returned paths matches input ordering so - the gallery index is preserved. - """ - targets: list[tuple[int, str, str]] = [] - for i, url in enumerate(image_urls): - if not url: - continue - filename = f"{i:02d}_{self._hash_for(url)}.{self._ext_for(url)}" - rel = os.path.join("flavors", dir_slug, filename) - targets.append((i, url, rel)) - - if not targets: - return [] - - results: dict[int, str] = {} - with ThreadPoolExecutor(max_workers=_DOWNLOAD_WORKERS) as ex: - futures = { - ex.submit(self.download_image_to_path, url, rel): i for i, url, rel in targets - } - for fut, i in futures.items(): - saved = fut.result() - if saved: - results[i] = saved - return [results[i] for i, _, _ in targets if i in results] - - @staticmethod - def _category_for_family(family_name: str) -> Category: - """get_or_create a Category from a family name. slugify('Iced Tea') == 'iced-tea', - which matches the pre-existing slugs, so known families are reused not duplicated.""" - slug = slugify(family_name) - return Category.objects.get_or_create(slug=slug, defaults={"name": family_name})[0] - - @staticmethod - def _is_pack(title_lower: str, tags_lower: list[str]) -> bool: - if any(k in title_lower for k in _PACK_TITLE_KEYWORDS): - return True - return "shaker" in title_lower or "merch" in tags_lower - - def resolve_category(self, product, title_lower, tags_lower, packs_category) -> Category | None: - """Pick a Category for a Shopify product. None means skip (no usable signal). - - Order: pack detection (title/tags) -> accessory title -> product_type family - (dynamic, auto-creates) -> tag-based family fallback -> shaker tag -> skip. - """ - if self._is_pack(title_lower, tags_lower): - return packs_category - - if any(k in title_lower for k in _ACCESSORY_TITLE_KEYWORDS): - return packs_category - - family = _family_from_product_type(product.get("product_type", "")) - if family: - return self._category_for_family(family) - - for keywords, name in _TAG_FAMILY: - if any(k in tags_lower for k in keywords): - return self._category_for_family(name) - - if "shaker" in tags_lower: - return packs_category - return None - - @staticmethod - def _is_syrup_variant_product(product) -> bool: - """True for the single 'Syrup' article whose variants ARE the flavors. - - Unlike every other line, syrup has no standalone per-flavor products — it - ships as one bundle ('3er Box Syrup') with a 'Flavor' option. Its variants - are expanded into individual Flavor rows (see _sync_syrup_variants). - """ - if _family_from_product_type(product.get("product_type", "")) != "Syrup": - return False - has_flavor_option = any( - (o.get("name") or "").lower() == "flavor" for o in product.get("options", []) - ) - return has_flavor_option and len(product.get("variants", [])) > 1 - - def _upsert_flavor( - self, - *, - external_id, - name, - category, - description, - shop_url, - is_available, - image_url, - image_urls, - dir_slug, - ) -> bool: - """Find-or-create one Flavor row and refresh its fields. Returns True if created. - - Match order: external_id -> exact name in category -> case-insensitive name. - """ - flavor = Flavor.objects.filter(external_id=external_id).first() - if not flavor: - flavor = Flavor.objects.filter(name=name, category=category).first() - if not flavor: - flavor = Flavor.objects.filter(name__iexact=name, category=category).first() - - created = flavor is None - if flavor is None: - flavor = Flavor(external_id=external_id, name=name, category=category, is_legacy=False) - else: - if not flavor.external_id: - flavor.external_id = external_id - if flavor.is_legacy: - flavor.is_legacy = False - - if ( - flavor.name != name - and not Flavor.objects.filter(name=name, category=category) - .exclude(pk=flavor.pk) - .exists() - ): - flavor.name = name - - flavor.description = description - flavor.shop_url = shop_url - flavor.is_available = is_available - - local_paths = self.download_gallery(image_urls, dir_slug) - if local_paths: - flavor.local_image_paths = local_paths - # Drop main_image_path if it points to a file no longer in the gallery - if flavor.main_image_path and flavor.main_image_path not in local_paths: - flavor.main_image_path = None - elif not flavor.local_image_paths: - # No new paths and nothing previously stored — leave both empty - flavor.main_image_path = None - - flavor.image_url = image_url - flavor.image_urls = image_urls - flavor.save() - return created - - def _sync_syrup_variants(self, product, synced_external_ids) -> tuple[int, int]: - """Expand the syrup bundle's variants into individual Syrup flavors. - - Each variant id becomes a Flavor.external_id (BigInt, unique) under the Syrup - category; the variant's featured_image is its per-flavor image. Returns - (created, updated) counts. - """ - category = self._category_for_family("Syrup") - description = self.clean_html(product.get("body_html", "")) - handle = product.get("handle") - shop_url = f"https://weareholy.com/products/{handle}" if handle else None - - created = updated = 0 - for v in product.get("variants", []): - name = (v.get("option1") or v.get("title") or "").strip() - variant_id = v.get("id") - if not name or variant_id is None: - continue - featured = v.get("featured_image") or {} - image_url = featured.get("src") - image_urls = [image_url] if image_url else [] - if self._upsert_flavor( - external_id=variant_id, - name=name, - category=category, - description=description, - shop_url=shop_url, - is_available=bool(v.get("available")), - image_url=image_url, - image_urls=image_urls, - dir_slug=str(variant_id), - ): - created += 1 - else: - updated += 1 - synced_external_ids.append(variant_id) - return created, updated - - 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.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", []) - self.stdout.write(f"Found {len(products)} products.") - - packs_category = Category.objects.get_or_create( - slug=_PACKS_SLUG, defaults={"name": _PACKS_NAME} - )[0] - - created_count = 0 - updated_count = 0 - synced_external_ids: list[int] = [] - - for p in products: - title = p.get("title", "").strip() - tags = p.get("tags", []) - if isinstance(tags, str): - tags = [t.strip() for t in tags.split(",")] - - tags_lower = [t.lower() for t in tags] - title_lower = title.lower() - - # Syrup ships as one article whose variants are the flavors — expand them. - if self._is_syrup_variant_product(p): - c, u = self._sync_syrup_variants(p, synced_external_ids) - created_count += c - updated_count += u - continue - - category = self.resolve_category(p, title_lower, tags_lower, packs_category) - if not category: - 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 - image_urls_list = [img.get("src") for img in images if img.get("src")] - description = self.clean_html(p.get("body_html", "")) - handle = p.get("handle") - shop_url = f"https://weareholy.com/products/{handle}" if handle else None - - if self._upsert_flavor( - external_id=p["id"], - name=title, - category=category, - description=description, - shop_url=shop_url, - is_available=is_available, - image_url=image_url, - image_urls=image_urls_list, - dir_slug=str(p["id"]), - ): - created_count += 1 - else: - updated_count += 1 - 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}" - ) - ) diff --git a/backend/api/migrations/0001_initial.py b/backend/api/migrations/0001_initial.py deleted file mode 100644 index 4ed032b..0000000 --- a/backend/api/migrations/0001_initial.py +++ /dev/null @@ -1,84 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-15 19:16 - -import django.contrib.auth.models -import django.contrib.auth.validators -import django.core.validators -import django.db.models.deletion -import django.utils.timezone -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), - ] - - operations = [ - migrations.CreateModel( - name='Category', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=50, unique=True)), - ('slug', models.SlugField(unique=True)), - ], - options={ - 'verbose_name_plural': 'Categories', - }, - ), - migrations.CreateModel( - name='User', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), - ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('theme', models.CharField(choices=[('latte', 'Latte'), ('frappe', 'Frappé'), ('macchiato', 'Macchiato'), ('mocha', 'Mocha')], default='mocha', max_length=20)), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), - ], - options={ - 'verbose_name': 'user', - 'verbose_name_plural': 'users', - 'abstract': False, - }, - managers=[ - ('objects', django.contrib.auth.models.UserManager()), - ], - ), - migrations.CreateModel( - name='Flavor', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100, unique=True)), - ('description', models.TextField(blank=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='flavors', to='api.category')), - ], - ), - migrations.CreateModel( - name='Rating', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('score', models.IntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(10)])), - ('comment', models.TextField(blank=True, null=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('flavor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ratings', to='api.flavor')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ratings', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'ordering': ['-created_at'], - 'unique_together': {('user', 'flavor')}, - }, - ), - ] diff --git a/backend/api/migrations/0002_flavor_external_id_flavor_image_url_and_more.py b/backend/api/migrations/0002_flavor_external_id_flavor_image_url_and_more.py deleted file mode 100644 index 706240a..0000000 --- a/backend/api/migrations/0002_flavor_external_id_flavor_image_url_and_more.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-15 19:42 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='flavor', - name='external_id', - field=models.BigIntegerField(blank=True, null=True, unique=True), - ), - migrations.AddField( - model_name='flavor', - name='image_url', - field=models.URLField(blank=True, max_length=500, null=True), - ), - migrations.AddField( - model_name='flavor', - name='is_available', - field=models.BooleanField(default=True), - ), - ] diff --git a/backend/api/migrations/0003_flavor_shop_url.py b/backend/api/migrations/0003_flavor_shop_url.py deleted file mode 100644 index b513268..0000000 --- a/backend/api/migrations/0003_flavor_shop_url.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-15 19:46 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0002_flavor_external_id_flavor_image_url_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='flavor', - name='shop_url', - field=models.URLField(blank=True, max_length=500, null=True), - ), - ] diff --git a/backend/api/migrations/0004_user_email_confirmation_code_user_pending_email.py b/backend/api/migrations/0004_user_email_confirmation_code_user_pending_email.py deleted file mode 100644 index df329f8..0000000 --- a/backend/api/migrations/0004_user_email_confirmation_code_user_pending_email.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-15 19:58 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0003_flavor_shop_url'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='email_confirmation_code', - field=models.CharField(blank=True, max_length=6, null=True), - ), - migrations.AddField( - model_name='user', - name='pending_email', - field=models.EmailField(blank=True, max_length=254, null=True), - ), - ] diff --git a/backend/api/migrations/0005_alter_user_theme.py b/backend/api/migrations/0005_alter_user_theme.py deleted file mode 100644 index 277f4cc..0000000 --- a/backend/api/migrations/0005_alter_user_theme.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-15 22:48 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0004_user_email_confirmation_code_user_pending_email'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='theme', - field=models.CharField(choices=[('latte', 'Latte'), ('frappe', 'Frappé'), ('macchiato', 'Macchiato'), ('mocha', 'Mocha'), ('pink', 'Pastel Pink')], default='mocha', max_length=20), - ), - ] diff --git a/backend/api/migrations/0006_reply.py b/backend/api/migrations/0006_reply.py deleted file mode 100644 index 5334234..0000000 --- a/backend/api/migrations/0006_reply.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-15 22:57 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0005_alter_user_theme'), - ] - - operations = [ - migrations.CreateModel( - name='Reply', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('text', models.TextField()), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('rating', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='api.rating')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'ordering': ['created_at'], - }, - ), - ] diff --git a/backend/api/migrations/0007_flavor_image_flavor_is_legacy_alter_flavor_image_url.py b/backend/api/migrations/0007_flavor_image_flavor_is_legacy_alter_flavor_image_url.py deleted file mode 100644 index c28d7c0..0000000 --- a/backend/api/migrations/0007_flavor_image_flavor_is_legacy_alter_flavor_image_url.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-16 12:04 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0006_reply'), - ] - - operations = [ - migrations.AddField( - model_name='flavor', - name='image', - field=models.ImageField(blank=True, null=True, upload_to='flavors/'), - ), - migrations.AddField( - model_name='flavor', - name='is_legacy', - field=models.BooleanField(default=False), - ), - migrations.AlterField( - model_name='flavor', - name='image_url', - field=models.URLField(blank=True, max_length=1000, null=True), - ), - ] diff --git a/backend/api/migrations/0008_alter_user_theme.py b/backend/api/migrations/0008_alter_user_theme.py deleted file mode 100644 index 65155ec..0000000 --- a/backend/api/migrations/0008_alter_user_theme.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-16 13:11 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0007_flavor_image_flavor_is_legacy_alter_flavor_image_url'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='theme', - field=models.CharField(choices=[('latte', 'Latte'), ('frappe', 'Frappé'), ('macchiato', 'Macchiato'), ('mocha', 'Mocha'), ('pink', 'Pastel Pink'), ('atom', 'Atom One Dark'), ('dracula', 'Dracula'), ('gruvbox', 'Gruvbox Dark'), ('nord', 'Nord'), ('cyberpunk', 'Cyberpunk'), ('forest', 'Forest Dark')], default='mocha', max_length=20), - ), - ] diff --git a/backend/api/migrations/0009_alter_user_theme.py b/backend/api/migrations/0009_alter_user_theme.py deleted file mode 100644 index 41ab65b..0000000 --- a/backend/api/migrations/0009_alter_user_theme.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-16 13:12 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0008_alter_user_theme'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='theme', - field=models.CharField(choices=[('latte', 'Latte'), ('pink', 'Pastel Pink'), ('solarized_light', 'Solarized Light'), ('one_light', 'One Light'), ('paper', 'Paper White'), ('frappe', 'Frappé'), ('macchiato', 'Macchiato'), ('mocha', 'Mocha'), ('atom', 'Atom One Dark'), ('dracula', 'Dracula'), ('gruvbox', 'Gruvbox Dark'), ('nord', 'Nord'), ('cyberpunk', 'Cyberpunk'), ('forest', 'Forest Dark')], default='mocha', max_length=20), - ), - ] diff --git a/backend/api/migrations/0010_user_avatar.py b/backend/api/migrations/0010_user_avatar.py deleted file mode 100644 index c1c5df5..0000000 --- a/backend/api/migrations/0010_user_avatar.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-16 14:16 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0009_alter_user_theme'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='avatar', - field=models.ImageField(blank=True, null=True, upload_to='avatars/'), - ), - ] diff --git a/backend/api/migrations/0011_alter_flavor_name_and_more.py b/backend/api/migrations/0011_alter_flavor_name_and_more.py deleted file mode 100644 index 6f11362..0000000 --- a/backend/api/migrations/0011_alter_flavor_name_and_more.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-16 18:16 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0010_user_avatar'), - ] - - operations = [ - migrations.AlterField( - model_name='flavor', - name='name', - field=models.CharField(max_length=100), - ), - migrations.AddConstraint( - model_name='flavor', - constraint=models.UniqueConstraint(fields=('name', 'category'), name='unique_flavor_name_per_category'), - ), - ] diff --git a/backend/api/migrations/0012_user_following.py b/backend/api/migrations/0012_user_following.py deleted file mode 100644 index ea506b2..0000000 --- a/backend/api/migrations/0012_user_following.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-16 18:43 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0011_alter_flavor_name_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='following', - field=models.ManyToManyField(blank=True, related_name='followers', to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/backend/api/migrations/0013_user_language.py b/backend/api/migrations/0013_user_language.py deleted file mode 100644 index 3747a0f..0000000 --- a/backend/api/migrations/0013_user_language.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-17 02:16 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0012_user_following'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='language', - field=models.CharField(choices=[('en', 'English'), ('de', 'Deutsch')], default='en', max_length=2), - ), - ] diff --git a/backend/api/migrations/0014_alter_user_theme_notification.py b/backend/api/migrations/0014_alter_user_theme_notification.py deleted file mode 100644 index af6a2c1..0000000 --- a/backend/api/migrations/0014_alter_user_theme_notification.py +++ /dev/null @@ -1,36 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-17 17:05 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0013_user_language'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='theme', - field=models.CharField(choices=[('holy_light', 'Holy Light'), ('holy_dark', 'Holy Dark'), ('latte', 'Latte'), ('pink', 'Pastel Pink'), ('solarized_light', 'Solarized Light'), ('one_light', 'One Light'), ('paper', 'Paper White'), ('frappe', 'Frappé'), ('macchiato', 'Macchiato'), ('mocha', 'Mocha'), ('atom', 'Atom One Dark'), ('dracula', 'Dracula'), ('gruvbox', 'Gruvbox Dark'), ('nord', 'Nord'), ('cyberpunk', 'Cyberpunk'), ('forest', 'Forest Dark')], default='holy_light', max_length=20), - ), - migrations.CreateModel( - name='Notification', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('notification_type', models.CharField(choices=[('reply', 'Reply'), ('mention', 'Mention')], max_length=10)), - ('is_read', models.BooleanField(default=False)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='actions', to=settings.AUTH_USER_MODEL)), - ('rating', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='api.rating')), - ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), - ('reply', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='api.reply')), - ], - options={ - 'ordering': ['-created_at'], - }, - ), - ] diff --git a/backend/api/migrations/0015_ticket_ticketmessage_userip.py b/backend/api/migrations/0015_ticket_ticketmessage_userip.py deleted file mode 100644 index 4e38ea7..0000000 --- a/backend/api/migrations/0015_ticket_ticketmessage_userip.py +++ /dev/null @@ -1,56 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-17 22:17 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0014_alter_user_theme_notification'), - ] - - operations = [ - migrations.CreateModel( - name='Ticket', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('subject', models.CharField(max_length=200)), - ('description', models.TextField()), - ('status', models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed')], default='open', max_length=20)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tickets', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'ordering': ['-updated_at'], - }, - ), - migrations.CreateModel( - name='TicketMessage', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('text', models.TextField()), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='api.ticket')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - options={ - 'ordering': ['created_at'], - }, - ), - migrations.CreateModel( - name='UserIP', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('ip_address', models.GenericIPAddressField()), - ('last_login', models.DateTimeField(auto_now=True)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ips', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'ordering': ['-last_login'], - 'unique_together': {('user', 'ip_address')}, - }, - ), - ] diff --git a/backend/api/migrations/0016_notification_ticket_and_more.py b/backend/api/migrations/0016_notification_ticket_and_more.py deleted file mode 100644 index b41f3c3..0000000 --- a/backend/api/migrations/0016_notification_ticket_and_more.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-18 02:55 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0015_ticket_ticketmessage_userip'), - ] - - operations = [ - migrations.AddField( - model_name='notification', - name='ticket', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='api.ticket'), - ), - migrations.AlterField( - model_name='notification', - name='notification_type', - field=models.CharField(choices=[('reply', 'Reply'), ('mention', 'Mention'), ('ticket_new', 'New Ticket'), ('ticket_reply', 'Ticket Reply')], max_length=20), - ), - ] diff --git a/backend/api/migrations/0017_profilecomment.py b/backend/api/migrations/0017_profilecomment.py deleted file mode 100644 index 0af9071..0000000 --- a/backend/api/migrations/0017_profilecomment.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-18 03:31 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0016_notification_ticket_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='ProfileComment', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('text', models.TextField()), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='authored_profile_comments', to=settings.AUTH_USER_MODEL)), - ('profile_owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='profile_comments', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'ordering': ['-created_at'], - }, - ), - ] diff --git a/backend/api/migrations/0018_alter_category_options.py b/backend/api/migrations/0018_alter_category_options.py deleted file mode 100644 index d7451d1..0000000 --- a/backend/api/migrations/0018_alter_category_options.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-18 03:39 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0017_profilecomment'), - ] - - operations = [ - migrations.AlterModelOptions( - name='category', - options={'ordering': ['name'], 'verbose_name_plural': 'Categories'}, - ), - ] diff --git a/backend/api/migrations/0019_alter_flavor_options.py b/backend/api/migrations/0019_alter_flavor_options.py deleted file mode 100644 index db2c257..0000000 --- a/backend/api/migrations/0019_alter_flavor_options.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-18 03:42 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0018_alter_category_options'), - ] - - operations = [ - migrations.AlterModelOptions( - name='flavor', - options={'ordering': ['name']}, - ), - ] diff --git a/backend/api/migrations/0020_notification_profile_comment_and_more.py b/backend/api/migrations/0020_notification_profile_comment_and_more.py deleted file mode 100644 index 2223a30..0000000 --- a/backend/api/migrations/0020_notification_profile_comment_and_more.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-18 04:44 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0019_alter_flavor_options'), - ] - - operations = [ - migrations.AddField( - model_name='notification', - name='profile_comment', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='api.profilecomment'), - ), - migrations.AlterField( - model_name='notification', - name='notification_type', - field=models.CharField(choices=[('reply', 'Reply'), ('mention', 'Mention'), ('ticket_new', 'New Ticket'), ('ticket_reply', 'Ticket Reply'), ('profile_comment', 'Profile Comment')], max_length=20), - ), - ] diff --git a/backend/api/migrations/0021_alter_user_theme.py b/backend/api/migrations/0021_alter_user_theme.py deleted file mode 100644 index 3bba0ff..0000000 --- a/backend/api/migrations/0021_alter_user_theme.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-18 05:43 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0020_notification_profile_comment_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='theme', - field=models.CharField(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'), ('dracula', 'Dracula'), ('nord', 'Nordic Frost'), ('gruvbox', 'Gruvbox Retro'), ('oceanic', 'Oceanic Deep')], default='holy_light', max_length=20), - ), - ] diff --git a/backend/api/migrations/0022_alter_user_theme.py b/backend/api/migrations/0022_alter_user_theme.py deleted file mode 100644 index 2ee3336..0000000 --- a/backend/api/migrations/0022_alter_user_theme.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-18 06:18 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0021_alter_user_theme'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='theme', - field=models.CharField(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)')], default='holy_light', max_length=20), - ), - ] diff --git a/backend/api/migrations/0023_banner.py b/backend/api/migrations/0023_banner.py deleted file mode 100644 index 1646d3b..0000000 --- a/backend/api/migrations/0023_banner.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-18 11:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0022_alter_user_theme'), - ] - - operations = [ - migrations.CreateModel( - name='Banner', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), - ('slug', models.SlugField(unique=True)), - ('description', models.TextField(blank=True)), - ('is_active', models.BooleanField(default=False)), - ('settings', models.JSONField(blank=True, default=dict)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ], - ), - ] diff --git a/backend/api/migrations/0024_seed_banners.py b/backend/api/migrations/0024_seed_banners.py deleted file mode 100644 index afaa6dc..0000000 --- a/backend/api/migrations/0024_seed_banners.py +++ /dev/null @@ -1,59 +0,0 @@ -from django.db import migrations - -def seed_banners(apps, schema_editor): - Banner = apps.get_model('api', 'Banner') - - # Create the original Generative Web - Banner.objects.get_or_create( - slug='generative-web', - defaults={ - 'name': 'Generative Web', - 'description': 'The original complex web interactive banner.', - 'settings': { - 'nodeCountBase': 60, - 'connectionDist': 140, - 'speed': 0.005, - 'opacity': 0.35 - }, - 'is_active': False - } - ) - - # Create Matrix Code - Banner.objects.get_or_create( - slug='matrix', - defaults={ - 'name': 'Matrix Code', - 'description': 'A matrix-style falling code banner using the username.', - 'settings': { - 'opacity': 0.6 - }, - 'is_active': False - } - ) - - # Create Nebula Cloud and set as active - Banner.objects.get_or_create( - slug='nebula', - defaults={ - 'name': 'Nebula Cloud', - 'description': 'An interactive space-themed nebular cloud with fluid movement.', - 'settings': { - 'particleCount': 200, - 'speed': 0.005, - 'opacity': 0.8, - 'attractionForce': 0.2 - }, - 'is_active': True - } - ) - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0023_banner'), - ] - - operations = [ - migrations.RunPython(seed_banners), - ] diff --git a/backend/api/migrations/0025_banner_schema.py b/backend/api/migrations/0025_banner_schema.py deleted file mode 100644 index 738db2d..0000000 --- a/backend/api/migrations/0025_banner_schema.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-18 13:29 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0024_seed_banners'), - ] - - operations = [ - migrations.AddField( - model_name='banner', - name='schema', - field=models.JSONField(blank=True, default=list), - ), - ] diff --git a/backend/api/migrations/0026_banner_is_enabled_user_selected_banner_and_more.py b/backend/api/migrations/0026_banner_is_enabled_user_selected_banner_and_more.py deleted file mode 100644 index bb8515c..0000000 --- a/backend/api/migrations/0026_banner_is_enabled_user_selected_banner_and_more.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-18 16:11 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0025_banner_schema'), - ] - - operations = [ - migrations.AddField( - model_name='banner', - name='is_enabled', - field=models.BooleanField(default=True, help_text='Allows users to select this banner.'), - ), - migrations.AddField( - model_name='user', - name='selected_banner', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='api.banner'), - ), - migrations.AlterField( - model_name='banner', - name='is_active', - field=models.BooleanField(default=False, help_text='Designates this as the global default banner.'), - ), - ] diff --git a/backend/api/migrations/0027_alter_notification_notification_type.py b/backend/api/migrations/0027_alter_notification_notification_type.py deleted file mode 100644 index 7208745..0000000 --- a/backend/api/migrations/0027_alter_notification_notification_type.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-19 14:56 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0026_banner_is_enabled_user_selected_banner_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='notification', - name='notification_type', - field=models.CharField(choices=[('reply', 'Reply'), ('mention', 'Mention'), ('follow', 'Follow'), ('ticket_new', 'New Ticket'), ('ticket_reply', 'Ticket Reply'), ('profile_comment', 'Profile Comment')], max_length=20), - ), - ] diff --git a/backend/api/migrations/0028_job_systemconfig.py b/backend/api/migrations/0028_job_systemconfig.py deleted file mode 100644 index f4e6b57..0000000 --- a/backend/api/migrations/0028_job_systemconfig.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-19 16:26 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0027_alter_notification_notification_type'), - ] - - operations = [ - migrations.CreateModel( - name='Job', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(choices=[('sync_flavors', 'Sync Shopify Flavors'), ('cleanup_duplicates', 'Cleanup Duplicate Flavors'), ('backup_db', 'Database Backup'), ('seed_legacy', 'Seed Legacy Data')], max_length=100)), - ('status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('completed', 'Completed'), ('failed', 'Failed')], default='pending', max_length=20)), - ('last_run', models.DateTimeField(blank=True, null=True)), - ('next_run', models.DateTimeField(blank=True, null=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)), - ], - ), - migrations.CreateModel( - name='SystemConfig', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('site_name', models.CharField(default='Holy Flavors Archive', max_length=100)), - ('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)), - ], - options={ - 'verbose_name': 'System Configuration', - }, - ), - ] diff --git a/backend/api/migrations/0029_alter_job_name.py b/backend/api/migrations/0029_alter_job_name.py deleted file mode 100644 index 8cb8538..0000000 --- a/backend/api/migrations/0029_alter_job_name.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-19 17:22 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0028_job_systemconfig'), - ] - - operations = [ - migrations.AlterField( - model_name='job', - name='name', - field=models.CharField(choices=[('sync_flavors', 'Sync Holy Flavors'), ('cleanup_duplicates', 'Cleanup Duplicate Flavors'), ('backup_db', 'Database Backup'), ('seed_legacy', 'Seed Legacy Data')], max_length=100), - ), - ] diff --git a/backend/api/migrations/0030_flavor_image_urls.py b/backend/api/migrations/0030_flavor_image_urls.py deleted file mode 100644 index 569d61e..0000000 --- a/backend/api/migrations/0030_flavor_image_urls.py +++ /dev/null @@ -1,18 +0,0 @@ -# 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/migrations/0031_alter_job_name.py b/backend/api/migrations/0031_alter_job_name.py deleted file mode 100644 index b78c01d..0000000 --- a/backend/api/migrations/0031_alter_job_name.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 6.0.4 on 2026-04-24 16:16 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0030_flavor_image_urls'), - ] - - operations = [ - migrations.AlterField( - model_name='job', - name='name', - field=models.CharField(choices=[('sync_flavors', 'Sync Holy Flavors'), ('cleanup_duplicates', 'Cleanup Duplicate Flavors'), ('backup_db', 'Database Backup'), ('seed_legacy', 'Seed Legacy Data'), ('seed_banners', 'Seed Banner Configurations')], max_length=100), - ), - ] diff --git a/backend/api/migrations/0032_flavor_local_image_paths_flavor_main_image_path.py b/backend/api/migrations/0032_flavor_local_image_paths_flavor_main_image_path.py deleted file mode 100644 index 64af260..0000000 --- a/backend/api/migrations/0032_flavor_local_image_paths_flavor_main_image_path.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 6.0.4 on 2026-04-26 18:45 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0031_alter_job_name'), - ] - - operations = [ - migrations.AddField( - model_name='flavor', - name='local_image_paths', - field=models.JSONField(blank=True, default=list), - ), - migrations.AddField( - model_name='flavor', - name='main_image_path', - field=models.CharField(blank=True, max_length=500, null=True), - ), - ] diff --git a/backend/api/migrations/0033_user_drawer_anchor.py b/backend/api/migrations/0033_user_drawer_anchor.py deleted file mode 100644 index 1a4d069..0000000 --- a/backend/api/migrations/0033_user_drawer_anchor.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 6.0.4 on 2026-04-27 01:54 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0032_flavor_local_image_paths_flavor_main_image_path'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='drawer_anchor', - field=models.CharField(choices=[('left', 'Left'), ('right', 'Right')], default='left', max_length=5), - ), - ] diff --git a/backend/api/migrations/0034_alter_user_drawer_anchor.py b/backend/api/migrations/0034_alter_user_drawer_anchor.py deleted file mode 100644 index f517fe2..0000000 --- a/backend/api/migrations/0034_alter_user_drawer_anchor.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 6.0.4 on 2026-05-08 20:04 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0033_user_drawer_anchor'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='drawer_anchor', - field=models.CharField(choices=[('left', 'Left'), ('right', 'Right')], default='right', max_length=5), - ), - ] diff --git a/backend/api/migrations/__init__.py b/backend/api/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/api/models/__init__.py b/backend/api/models/__init__.py deleted file mode 100644 index cc627e7..0000000 --- a/backend/api/models/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 7d1673b..0000000 --- a/backend/api/models/flavor.py +++ /dev/null @@ -1,53 +0,0 @@ -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) - local_image_paths = models.JSONField(blank=True, default=list) - main_image_path = models.CharField(max_length=500, 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 deleted file mode 100644 index 1947676..0000000 --- a/backend/api/models/rating.py +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index c12f2c3..0000000 --- a/backend/api/models/social.py +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 789d473..0000000 --- a/backend/api/models/support.py +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index ceb7123..0000000 --- a/backend/api/models/system.py +++ /dev/null @@ -1,78 +0,0 @@ -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"), - ("seed_banners", "Seed Banner Configurations"), - ] - 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 deleted file mode 100644 index f730659..0000000 --- a/backend/api/models/user.py +++ /dev/null @@ -1,77 +0,0 @@ -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"), - ] - DRAWER_ANCHOR_CHOICES = [ - ("left", "Left"), - ("right", "Right"), - ] - theme = models.CharField(max_length=20, choices=THEME_CHOICES, default="holy_light") - language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default="en") - drawer_anchor = models.CharField(max_length=5, choices=DRAWER_ANCHOR_CHOICES, default="right") - 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/__init__.py b/backend/api/serializers/__init__.py deleted file mode 100644 index 220c8de..0000000 --- a/backend/api/serializers/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index a23ac57..0000000 --- a/backend/api/serializers/_helpers.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import Any - -from django.conf import settings - - -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 - - -def absolute_media_url(serializer: Any, rel_path: str | None) -> str | None: - if not rel_path: - return None - media_url = settings.MEDIA_URL.rstrip("/") + "/" - url = media_url + rel_path.lstrip("/") - request = serializer.context.get("request") - if request: - return request.build_absolute_uri(url) - return url diff --git a/backend/api/serializers/flavor.py b/backend/api/serializers/flavor.py deleted file mode 100644 index c7f231f..0000000 --- a/backend/api/serializers/flavor.py +++ /dev/null @@ -1,89 +0,0 @@ -from django.db.models import Count -from rest_framework import serializers - -from api.models import Category, Flavor, Rating -from api.serializers._helpers import absolute_image_url, absolute_media_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() - image_urls = serializers.SerializerMethodField() - rating_distribution = 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_urls", - "image", - "is_available", - "is_legacy", - "shop_url", - "rating_distribution", - ] - extra_kwargs = {"image": {"write_only": True}} - - def get_image_url(self, obj: Flavor) -> str | None: - if obj.main_image_path: - url = absolute_media_url(self, obj.main_image_path) - if url: - return url - if obj.local_image_paths: - url = absolute_media_url(self, obj.local_image_paths[0]) - if url: - return url - if obj.image: - return absolute_image_url(self, obj.image) - return obj.image_url - - def get_image_urls(self, obj: Flavor) -> list[str]: - if obj.local_image_paths: - urls = [absolute_media_url(self, p) for p in obj.local_image_paths] - return [u for u in urls if u] - legacy_urls: list[str] = [] - if obj.image: - absolute = absolute_image_url(self, obj.image) - if absolute: - legacy_urls.append(absolute) - legacy_urls.extend(obj.image_urls or []) - return legacy_urls - - def get_rating_distribution(self, obj: Flavor) -> dict[str, int]: - counts = dict.fromkeys((str(i) for i in range(1, 11)), 0) - for row in obj.ratings.values("score").annotate(c=Count("id")): - score = row["score"] - if 1 <= score <= 10: - counts[str(score)] = row["c"] - return counts - - 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 deleted file mode 100644 index 35a07a6..0000000 --- a/backend/api/serializers/rating.py +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 0b8e6d5..0000000 --- a/backend/api/serializers/social.py +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index a671438..0000000 --- a/backend/api/serializers/support.py +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 74ee6cd..0000000 --- a/backend/api/serializers/system.py +++ /dev/null @@ -1,81 +0,0 @@ -from datetime import timedelta - -from rest_framework import serializers - -from api.models import Banner, Job, SystemConfig - -_INTERVAL_PERIOD_TO_TIMEDELTA = { - "days": lambda every: timedelta(days=every), - "hours": lambda every: timedelta(hours=every), - "minutes": lambda every: timedelta(minutes=every), - "seconds": lambda every: timedelta(seconds=every), -} - - -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) - next_run = serializers.SerializerMethodField() - - def get_next_run(self, obj): - from django_celery_beat.models import PeriodicTask - - task = PeriodicTask.objects.filter(name=f"job:{obj.name}", enabled=True).first() - if not task or not task.interval: - return None - base = task.last_run_at or obj.last_run - if not base: - return None - delta_fn = _INTERVAL_PERIOD_TO_TIMEDELTA.get(task.interval.period) - if not delta_fn: - return None - return base + delta_fn(task.interval.every) - - 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 deleted file mode 100644 index 02b8de3..0000000 --- a/backend/api/serializers/user.py +++ /dev/null @@ -1,96 +0,0 @@ -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", - "drawer_anchor", - "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 deleted file mode 100644 index e69de29..0000000 diff --git a/backend/api/services/flavor.py b/backend/api/services/flavor.py deleted file mode 100644 index ec9bd45..0000000 --- a/backend/api/services/flavor.py +++ /dev/null @@ -1,54 +0,0 @@ -from django.db import transaction - -from api.models import Flavor, Notification, Rating, Reply - - -def _quality(rating: Rating) -> tuple[int, object]: - """Rank a rating for conflict resolution: longest comment wins, newer breaks ties.""" - return (len(rating.comment or ""), rating.created_at) - - -def merge_flavors(keep_flavor: Flavor, remove_flavor: Flavor) -> None: - """ - Merges remove_flavor into keep_flavor. - - Re-assigns all ratings. When a user rated both flavors, the higher-quality - review survives (longest comment, newer on ties) and its replies + - notifications are re-pointed to the surviving row. - """ - if keep_flavor.id == remove_flavor.id: - return - - with transaction.atomic(): - for incoming in Rating.objects.filter(flavor=remove_flavor): - existing = Rating.objects.filter(user=incoming.user, flavor=keep_flavor).first() - - if existing is None: - # No conflict — move the rating wholesale. - incoming.flavor = keep_flavor - incoming.save(update_fields=["flavor"]) - continue - - # Conflict: keep one row (unique_together user+flavor). If the - # incoming review is better, copy its content + timestamp onto the - # surviving row so the comment and its date stay consistent. - if _quality(incoming) > _quality(existing): - existing.score = incoming.score - existing.comment = incoming.comment - existing.created_at = incoming.created_at # auto_now_add ignores updates - existing.save(update_fields=["score", "comment", "created_at"]) - - # Preserve community feedback attached to the discarded rating. - Reply.objects.filter(rating=incoming).update(rating=existing) - Notification.objects.filter(rating=incoming).update(rating=existing) - incoming.delete() - - # Carry over the Shopify link if the kept flavor lacks one. external_id - # is unique, so the donor row must be gone before we assign it. - inherited_external_id = remove_flavor.external_id if not keep_flavor.external_id else None - - remove_flavor.delete() - - if inherited_external_id is not None: - keep_flavor.external_id = inherited_external_id - keep_flavor.save(update_fields=["external_id"]) diff --git a/backend/api/services/ip_logging.py b/backend/api/services/ip_logging.py deleted file mode 100644 index fbdca20..0000000 --- a/backend/api/services/ip_logging.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 3684e40..0000000 --- a/backend/api/services/mentions.py +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 2ce0166..0000000 --- a/backend/api/services/search.py +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 956e37a..0000000 --- a/backend/api/tasks.py +++ /dev/null @@ -1,107 +0,0 @@ -"""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 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. - - Scheduling (next_run) is owned by django_celery_beat.PeriodicTask; the Job - serializer derives next_run from it. This function only writes execution - state: status, last_run, last_output, error_message. - """ - 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() - job.save(update_fields=["status", "last_output", "error_message"]) - - return job.last_output - - -@shared_task( - name="api.sync_flavors", - autoretry_for=(Exception,), - retry_backoff=60, - retry_backoff_max=600, - retry_kwargs={"max_retries": 2}, -) -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.seed_banners") -def seed_banners_task() -> str: - return _run_command_job("seed_banners", "seed_banners") - - -@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/__init__.py b/backend/api/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/api/tests/test_auth.py b/backend/api/tests/test_auth.py deleted file mode 100644 index 0d1373d..0000000 --- a/backend/api/tests/test_auth.py +++ /dev/null @@ -1,60 +0,0 @@ -from django.conf import settings -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 _login(self): - return self.client.post( - reverse("token_obtain_pair"), - {"username": self.username, "password": self.password}, - format="json", - ) - - def test_obtain_pair_sets_cookies(self) -> None: - response = self._login() - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, {"status": "ok"}) - self.assertIn(settings.JWT_AUTH_COOKIE_ACCESS, response.cookies) - self.assertIn(settings.JWT_AUTH_COOKIE_REFRESH, response.cookies) - access = response.cookies[settings.JWT_AUTH_COOKIE_ACCESS] - self.assertTrue(access["httponly"]) - self.assertEqual(access["samesite"], settings.JWT_AUTH_COOKIE_SAMESITE) - - def test_cookie_authenticates_protected_endpoint(self) -> None: - self._login() - # APIClient persists Set-Cookie across calls, so the next request is - # authenticated by the cookie alone. - response = self.client.get("/api/notifications/", follow=True) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_refresh_uses_cookie_when_body_omitted(self) -> None: - self._login() - response = self.client.post(reverse("token_refresh"), format="json") - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, {"status": "ok"}) - self.assertIn(settings.JWT_AUTH_COOKIE_ACCESS, response.cookies) - - 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) - - def test_logout_clears_cookies_and_blacklists_refresh(self) -> None: - self._login() - response = self.client.post(reverse("auth_logout"), format="json") - self.assertEqual(response.status_code, status.HTTP_200_OK) - # set-cookie should be present with empty/expired value to clear them. - self.assertEqual(response.cookies[settings.JWT_AUTH_COOKIE_ACCESS].value, "") - self.assertEqual(response.cookies[settings.JWT_AUTH_COOKIE_REFRESH].value, "") diff --git a/backend/api/tests/test_flavors.py b/backend/api/tests/test_flavors.py deleted file mode 100644 index 9586bdd..0000000 --- a/backend/api/tests/test_flavors.py +++ /dev/null @@ -1,45 +0,0 @@ -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_merge.py b/backend/api/tests/test_merge.py deleted file mode 100644 index 8fdab3f..0000000 --- a/backend/api/tests/test_merge.py +++ /dev/null @@ -1,127 +0,0 @@ -from io import StringIO - -from django.test import TestCase - -from api.management.commands.cleanup_duplicates import Command -from api.models import Category, Flavor, Notification, Rating, Reply, User -from api.services.flavor import merge_flavors - - -class FlavorMergeTests(TestCase): - def setUp(self): - self.cat = Category.objects.create(name="Energy", slug="energy") - self.user1 = User.objects.create_user(username="user1") - self.user2 = User.objects.create_user(username="user2") - - self.keep = Flavor.objects.create(name="Dolphin Daiquiri", category=self.cat) - self.remove = Flavor.objects.create(name="Dolphin Daiquiri Energy", category=self.cat) - - def test_basic_merge(self): - # User 1 rates 'remove' - r1 = Rating.objects.create(user=self.user1, flavor=self.remove, score=8, comment="Good!") - - merge_flavors(self.keep, self.remove) - - self.assertFalse(Flavor.objects.filter(id=self.remove.id).exists()) - r1.refresh_from_db() - self.assertEqual(r1.flavor, self.keep) - - def test_merge_with_conflict_keeps_longest_comment(self): - # User 1 rates both - Rating.objects.create(user=self.user1, flavor=self.keep, score=5, comment="Short") - r_long = Rating.objects.create( - user=self.user1, flavor=self.remove, score=9, comment="Very long and detailed review" - ) - - # Add a reply to the one being deleted - reply = Reply.objects.create(user=self.user2, rating=r_long, text="Nice review!") - - merge_flavors(self.keep, self.remove) - - self.assertEqual(Rating.objects.filter(user=self.user1, flavor=self.keep).count(), 1) - kept_rating = Rating.objects.get(user=self.user1, flavor=self.keep) - self.assertEqual(kept_rating.comment, "Very long and detailed review") - self.assertEqual(kept_rating.score, 9) - - # Verify reply moved - reply.refresh_from_db() - self.assertEqual(reply.rating, kept_rating) - - def test_merge_with_conflict_preserves_replies_from_discarded(self): - Rating.objects.create(user=self.user1, flavor=self.keep, score=10, comment="Best ever!") - r_remove = Rating.objects.create( - user=self.user1, flavor=self.remove, score=8, comment="Meh" - ) - - reply_on_remove = Reply.objects.create(user=self.user2, rating=r_remove, text="I disagree") - - merge_flavors(self.keep, self.remove) - - kept_rating = Rating.objects.get(user=self.user1, flavor=self.keep) - self.assertEqual(kept_rating.comment, "Best ever!") - - reply_on_remove.refresh_from_db() - self.assertEqual(reply_on_remove.rating, kept_rating) - - def test_merge_conflict_repoints_notifications(self): - # User 1 rated both; the discarded rating has a notification attached. - Rating.objects.create(user=self.user1, flavor=self.keep, score=10, comment="Best ever!") - r_remove = Rating.objects.create( - user=self.user1, flavor=self.remove, score=8, comment="Meh" - ) - - notif = Notification.objects.create( - recipient=self.user1, - actor=self.user2, - notification_type="reply", - rating=r_remove, - ) - - merge_flavors(self.keep, self.remove) - - kept_rating = Rating.objects.get(user=self.user1, flavor=self.keep) - notif.refresh_from_db() - self.assertEqual(notif.rating, kept_rating) - - def test_merge_transfers_external_id_when_keep_lacks_one(self): - self.assertIsNone(self.keep.external_id) - self.remove.external_id = 99887766 - self.remove.save(update_fields=["external_id"]) - - merge_flavors(self.keep, self.remove) - - self.keep.refresh_from_db() - self.assertEqual(self.keep.external_id, 99887766) - - -class CleanupDuplicatesTests(TestCase): - def setUp(self): - self.cat = Category.objects.create(name="Energy", slug="energy") - self.other_cat = Category.objects.create(name="Iced Tea", slug="iced-tea") - - def test_normalization_logic(self): - f1 = Flavor.objects.create(name="Dolphin Daiquiri", category=self.cat) - f2 = Flavor.objects.create(name="Dolphin Daiquiri Energy", category=self.cat) - f3 = Flavor.objects.create(name="Dolphin Daiquiri", category=self.other_cat) - - out = StringIO() - cmd = Command() - cmd.stdout = out - cmd.handle() - - self.assertTrue(Flavor.objects.filter(id=f1.id).exists()) - self.assertFalse(Flavor.objects.filter(id=f2.id).exists()) - self.assertTrue(Flavor.objects.filter(id=f3.id).exists()) # Different category - - def test_various_suffixes(self): - Flavor.objects.create(name="Berry", category=self.cat) - Flavor.objects.create(name="Berry Energy", category=self.cat) - Flavor.objects.create(name="Berry Hydration", category=self.cat) - Flavor.objects.create(name="Berry Iced Tea", category=self.cat) - Flavor.objects.create(name="Berry (Legacy)", category=self.cat) - - cmd = Command() - cmd.stdout = StringIO() - cmd.handle() - - self.assertEqual(Flavor.objects.filter(category=self.cat).count(), 1) diff --git a/backend/api/tests/test_models.py b/backend/api/tests/test_models.py deleted file mode 100644 index 087dd1d..0000000 --- a/backend/api/tests/test_models.py +++ /dev/null @@ -1,19 +0,0 @@ -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/tests/test_recategorize.py b/backend/api/tests/test_recategorize.py deleted file mode 100644 index e092cdb..0000000 --- a/backend/api/tests/test_recategorize.py +++ /dev/null @@ -1,82 +0,0 @@ -from unittest.mock import patch - -from django.core.management import call_command -from django.test import TestCase - -from api.models import Category, Flavor, Rating, User - - -def _product(pid, title, product_type="", tags=None): - return {"id": pid, "title": title, "product_type": product_type, "tags": tags or []} - - -class RecategorizeTests(TestCase): - def setUp(self) -> None: - self.packs = Category.objects.create(name="Packs and other", slug="packs-and-other") - self.energy = Category.objects.create(name="Energy", slug="energy") - - def _run(self, products, **opts): - with patch( - "api.management.commands.recategorize_flavors.Command.fetch_products", - return_value=products, - ): - call_command("recategorize_flavors", **opts) - - def test_dry_run_does_not_move(self) -> None: - # Mis-shelved: a real syrup product currently parked in packs. - flavor = Flavor.objects.create(name="Tropical Punch", category=self.packs, external_id=42) - self._run([_product(42, "Tropical Punch", "42 - Syrup Bottle")]) - flavor.refresh_from_db() - self.assertEqual(flavor.category_id, self.packs.id) - self.assertFalse(Category.objects.filter(slug="syrup").exists()) - - def test_apply_moves_and_creates_category(self) -> None: - flavor = Flavor.objects.create(name="Tropical Punch", category=self.packs, external_id=42) - self._run([_product(42, "Tropical Punch", "42 - Syrup Bottle")], apply=True) - flavor.refresh_from_db() - self.assertEqual(flavor.category.slug, "syrup") - - def test_accessory_moves_out_of_drink_category(self) -> None: - # The original bug: a cleaning brush synced into Syrup. Recategorize - # must shelve it under packs, not leave it as a drink. - syrup = Category.objects.create(name="Syrup", slug="syrup") - flavor = Flavor.objects.create( - name="Reinigungsbürste – Syrup Bottle", category=syrup, external_id=42 - ) - self._run( - [_product(42, "Reinigungsbürste – Syrup Bottle", "42 - Syrup Bottle")], apply=True - ) - flavor.refresh_from_db() - self.assertEqual(flavor.category_id, self.packs.id) - - def test_unchanged_row_stays(self) -> None: - flavor = Flavor.objects.create(name="Mojito Macaw", category=self.energy, external_id=1) - self._run([_product(1, "Mojito Macaw", "01 - Energy Bundle")], apply=True) - flavor.refresh_from_db() - self.assertEqual(flavor.category_id, self.energy.id) - - def test_row_not_in_catalog_is_skipped(self) -> None: - flavor = Flavor.objects.create(name="Gone", category=self.packs, external_id=999) - self._run([_product(1, "Mojito Macaw", "01 - Energy Bundle")], apply=True) - flavor.refresh_from_db() - self.assertEqual(flavor.category_id, self.packs.id) - - def test_collision_skipped_without_flag(self) -> None: - syrup = Category.objects.create(name="Syrup", slug="syrup") - # Target already holds a row of the same name. - Flavor.objects.create(name="Dup", category=syrup, external_id=100) - moving = Flavor.objects.create(name="Dup", category=self.packs, external_id=42) - self._run([_product(42, "Dup", "42 - Syrup Bottle")], apply=True) - moving.refresh_from_db() - self.assertEqual(moving.category_id, self.packs.id) # untouched - self.assertEqual(Flavor.objects.filter(name="Dup").count(), 2) - - def test_collision_merges_with_flag(self) -> None: - user = User.objects.create_user(username="u", password="p") - syrup = Category.objects.create(name="Syrup", slug="syrup") - keep = Flavor.objects.create(name="Dup", category=syrup, external_id=100) - moving = Flavor.objects.create(name="Dup", category=self.packs, external_id=42) - Rating.objects.create(user=user, flavor=moving, score=5) - self._run([_product(42, "Dup", "42 - Syrup Bottle")], apply=True, merge_collisions=True) - self.assertFalse(Flavor.objects.filter(pk=moving.pk).exists()) - self.assertTrue(Rating.objects.filter(flavor=keep, user=user).exists()) diff --git a/backend/api/tests/test_sync_categories.py b/backend/api/tests/test_sync_categories.py deleted file mode 100644 index 5913f0f..0000000 --- a/backend/api/tests/test_sync_categories.py +++ /dev/null @@ -1,134 +0,0 @@ -from unittest.mock import patch - -from django.test import TestCase - -from api.management.commands.sync_flavors import Command, _family_from_product_type -from api.models import Category, Flavor - - -class FamilyFromProductTypeTests(TestCase): - def test_strips_prefix_and_form_word(self) -> None: - self.assertEqual(_family_from_product_type("42 - Syrup Bottle"), "Syrup") - self.assertEqual(_family_from_product_type("01 - Energy Bundle"), "Energy") - self.assertEqual(_family_from_product_type("04 - Energy Sachet"), "Energy") - - def test_multiword_family(self) -> None: - self.assertEqual(_family_from_product_type("02 - Iced Tea Bundle"), "Iced Tea") - - def test_non_flavor_families_return_none(self) -> None: - for pt in ("10 - Shaker", "11 - Merch", "12 - Value Pack", "16 - Packaging Material"): - self.assertIsNone(_family_from_product_type(pt), pt) - - def test_empty_or_missing(self) -> None: - self.assertIsNone(_family_from_product_type("")) - self.assertIsNone(_family_from_product_type("99 - ")) - - -class ResolveCategoryTests(TestCase): - def setUp(self) -> None: - self.cmd = Command() - self.packs = Category.objects.create(name="Packs and other", slug="packs-and-other") - - def _resolve(self, product, title="", tags=None): - tags = tags or [] - return self.cmd.resolve_category(product, title.lower(), tags, self.packs) - - def test_new_family_auto_creates_category(self) -> None: - cat = self._resolve({"product_type": "42 - Syrup Bottle"}, title="Tropical Punch") - self.assertIsNotNone(cat) - self.assertEqual(cat.slug, "syrup") - self.assertTrue(Category.objects.filter(slug="syrup").exists()) - - def test_accessory_title_routes_to_packs(self) -> None: - # Cleaning brush carries product_type "42 - Syrup Bottle" but is not a drink. - for title in ("Reinigungsbürste – Syrup Bottle", "Ersatzteile – Syrup Bottle", "Zubehör"): - cat = self._resolve({"product_type": "42 - Syrup Bottle"}, title=title) - self.assertEqual(cat.id, self.packs.id, title) - - def test_known_family_reuses_existing_slug(self) -> None: - existing = Category.objects.create(name="Iced Tea", slug="iced-tea") - cat = self._resolve({"product_type": "02 - Iced Tea Bundle"}, title="Erdbeere") - self.assertEqual(cat.id, existing.id) - - def test_pack_title_keyword_wins_over_family(self) -> None: - cat = self._resolve({"product_type": "43 - Syrup Bundle"}, title="3er Box Syrup") - self.assertEqual(cat.id, self.packs.id) - - def test_tag_fallback_when_no_product_type(self) -> None: - cat = self._resolve({}, title="Mojito Macaw", tags=["holy energy"]) - self.assertEqual(cat.slug, "energy") - - def test_no_signal_returns_none(self) -> None: - self.assertIsNone(self._resolve({"product_type": "20 - Other Items"}, title="Mystery")) - - -class SyrupVariantTests(TestCase): - def _bundle(self, variants=None): - return { - "product_type": "43 - Syrup Bundle", - "handle": "3er-box-syrup", - "body_html": "

Syrup desc

", - "options": [{"name": "Flavor"}], - "variants": variants - or [ - { - "id": 111, - "option1": "Strawberry", - "available": True, - "featured_image": {"src": "https://cdn/straw.webp"}, - }, - { - "id": 222, - "option1": "Mango", - "available": False, - "featured_image": {"src": "https://cdn/mango.webp"}, - }, - ], - } - - def test_detects_syrup_bundle(self) -> None: - self.assertTrue(Command._is_syrup_variant_product(self._bundle())) - - def test_cleaning_brush_not_expanded(self) -> None: - # Same Syrup family, but no Flavor option and a single variant. - brush = { - "product_type": "42 - Syrup Bottle", - "options": [{"name": "Title"}], - "variants": [{"id": 1, "option1": "Default Title"}], - } - self.assertFalse(Command._is_syrup_variant_product(brush)) - - def test_other_lines_with_flavor_option_not_expanded(self) -> None: - # Energy probe also has a Flavor option, but those flavors are standalone products. - probe = { - "product_type": "04 - Energy Sachet", - "options": [{"name": "Flavor"}], - "variants": [{"id": 1, "option1": "Mojito Macaw"}, {"id": 2, "option1": "Cherry"}], - } - self.assertFalse(Command._is_syrup_variant_product(probe)) - - def test_expands_variants_into_individual_flavors(self) -> None: - synced: list[int] = [] - with patch.object(Command, "download_gallery", return_value=[]): - created, updated = Command()._sync_syrup_variants(self._bundle(), synced) - - self.assertEqual((created, updated), (2, 0)) - syrup = Category.objects.get(slug="syrup") - self.assertEqual( - set(Flavor.objects.filter(category=syrup).values_list("name", flat=True)), - {"Strawberry", "Mango"}, - ) - straw = Flavor.objects.get(name="Strawberry") - self.assertEqual(straw.external_id, 111) - self.assertTrue(straw.is_available) - self.assertEqual(straw.image_url, "https://cdn/straw.webp") - self.assertEqual(straw.shop_url, "https://weareholy.com/products/3er-box-syrup") - self.assertFalse(Flavor.objects.get(name="Mango").is_available) - self.assertEqual(synced, [111, 222]) - - def test_re_sync_updates_not_duplicates(self) -> None: - with patch.object(Command, "download_gallery", return_value=[]): - Command()._sync_syrup_variants(self._bundle(), []) - created, updated = Command()._sync_syrup_variants(self._bundle(), []) - self.assertEqual((created, updated), (0, 2)) - self.assertEqual(Flavor.objects.filter(category__slug="syrup").count(), 2) diff --git a/backend/api/urls.py b/backend/api/urls.py deleted file mode 100644 index 8a942a6..0000000 --- a/backend/api/urls.py +++ /dev/null @@ -1,19 +0,0 @@ -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") - -urlpatterns = [ - path("", include(router.urls)), -] diff --git a/backend/api/utils/__init__.py b/backend/api/utils/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/api/utils/auth.py b/backend/api/utils/auth.py deleted file mode 100644 index d2d8091..0000000 --- a/backend/api/utils/auth.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Auth helpers for narrowing request.user to the concrete User type. - -DRF's `request.user` is typed as `User | AnonymousUser`. After a view enforces -`IsAuthenticated`, the union is misleading: every code path past the permission -check has the concrete user. These helpers express that narrowing for mypy and -keep view bodies readable. -""" - -from __future__ import annotations - -from rest_framework.exceptions import NotAuthenticated -from rest_framework.request import Request - -from api.models import User - - -def current_user(request: Request) -> User: - """Return the authenticated User for `request` or raise 401. - - Use after `IsAuthenticated` permission. The check is defensive — the - permission class already rejected anonymous calls. - """ - user = request.user - if not isinstance(user, User): - raise NotAuthenticated - return user diff --git a/backend/api/utils/cookie_auth.py b/backend/api/utils/cookie_auth.py deleted file mode 100644 index 1f2aab0..0000000 --- a/backend/api/utils/cookie_auth.py +++ /dev/null @@ -1,72 +0,0 @@ -"""httpOnly-cookie JWT auth. - -Reads `access_token` from a cookie set by the login/refresh endpoints. Falls -back to the Authorization header for backwards compatibility during rollout — -strip the fallback once all clients use cookies. -""" - -from __future__ import annotations - -from django.conf import settings -from drf_spectacular.extensions import OpenApiAuthenticationExtension -from rest_framework_simplejwt.authentication import JWTAuthentication - - -class CookieJWTAuthentication(JWTAuthentication): - def authenticate(self, request): - raw_token = request.COOKIES.get(settings.JWT_AUTH_COOKIE_ACCESS) - if raw_token is None: - return super().authenticate(request) - validated_token = self.get_validated_token(raw_token) - return self.get_user(validated_token), validated_token - - -class CookieJWTScheme(OpenApiAuthenticationExtension): - """Make drf-spectacular treat CookieJWTAuthentication like the SimpleJWT scheme.""" - - target_class = "api.utils.cookie_auth.CookieJWTAuthentication" - name = "cookieAuth" - - def get_security_definition(self, auto_schema): # type: ignore[no-untyped-def] - return { - "type": "apiKey", - "in": "cookie", - "name": settings.JWT_AUTH_COOKIE_ACCESS, - } - - -def set_jwt_cookies(response, access: str | None, refresh: str | None) -> None: - """Attach access/refresh tokens to the response as httpOnly cookies.""" - from datetime import timedelta - - secure = settings.JWT_AUTH_COOKIE_SECURE - samesite = settings.JWT_AUTH_COOKIE_SAMESITE - path = settings.JWT_AUTH_COOKIE_PATH - - if access: - access_lifetime: timedelta = settings.SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"] - response.set_cookie( - settings.JWT_AUTH_COOKIE_ACCESS, - access, - max_age=int(access_lifetime.total_seconds()), - httponly=True, - secure=secure, - samesite=samesite, - path=path, - ) - if refresh: - refresh_lifetime: timedelta = settings.SIMPLE_JWT["REFRESH_TOKEN_LIFETIME"] - response.set_cookie( - settings.JWT_AUTH_COOKIE_REFRESH, - refresh, - max_age=int(refresh_lifetime.total_seconds()), - httponly=True, - secure=secure, - samesite=samesite, - path=path, - ) - - -def clear_jwt_cookies(response) -> None: - response.delete_cookie(settings.JWT_AUTH_COOKIE_ACCESS, path=settings.JWT_AUTH_COOKIE_PATH) - response.delete_cookie(settings.JWT_AUTH_COOKIE_REFRESH, path=settings.JWT_AUTH_COOKIE_PATH) diff --git a/backend/api/views/__init__.py b/backend/api/views/__init__.py deleted file mode 100644 index 0cf38a2..0000000 --- a/backend/api/views/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index ce23acb..0000000 --- a/backend/api/views/_pagination.py +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 55c3154..0000000 --- a/backend/api/views/admin.py +++ /dev/null @@ -1,224 +0,0 @@ -import json -from typing import Any - -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 Flavor, Job, Rating, Reply, SystemConfig, Ticket, User -from api.services.flavor import merge_flavors -from api.serializers import ( - AdminUserDetailSerializer, - AdminUserListSerializer, - JobSerializer, - SystemConfigSerializer, -) -from api.tasks import ( - backup_db_task, - cleanup_duplicates_task, - seed_banners_task, - seed_legacy_task, - sync_flavors_task, -) -from api.utils.auth import current_user - -# Maps Job.name → (celery task name for beat schedules, callable for direct dispatch). -# Direct callables let .delay() honor CELERY_TASK_ALWAYS_EAGER in dev. -JOB_TASKS: dict[str, tuple[str, Any]] = { - "sync_flavors": ("api.sync_flavors", sync_flavors_task), - "cleanup_duplicates": ("api.cleanup_duplicates", cleanup_duplicates_task), - "backup_db": ("api.backup_db", backup_db_task), - "seed_legacy": ("api.seed_legacy", seed_legacy_task), - "seed_banners": ("api.seed_banners", seed_banners_task), -} - -JOB_TASK_MAP = {name: meta[0] for name, meta in JOB_TASKS.items()} - - -def _sync_periodic_task(job: Job) -> None: - """Create/update a django_celery_beat PeriodicTask for this Job. - - Called when the admin changes interval_hours. interval_hours == 0 disables - the schedule (PeriodicTask.enabled=False). - """ - from django_celery_beat.models import IntervalSchedule, PeriodicTask - - task_name = JOB_TASK_MAP.get(job.name) - if not task_name: - return - - periodic_name = f"job:{job.name}" - - if job.interval_hours <= 0: - PeriodicTask.objects.filter(name=periodic_name).update(enabled=False) - return - - schedule, _ = IntervalSchedule.objects.get_or_create( - every=job.interval_hours, - period=IntervalSchedule.HOURS, - ) - PeriodicTask.objects.update_or_create( - name=periodic_name, - defaults={ - "interval": schedule, - "task": task_name, - "enabled": True, - "kwargs": json.dumps({}), - }, - ) - - -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) - except Job.DoesNotExist: - return Response(status=status.HTTP_404_NOT_FOUND) - - meta = JOB_TASKS.get(job.name) - if not meta: - return Response( - {"error": f"No celery task registered for {job.name}"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - task_name, task_fn = meta - job.status = "pending" - job.error_message = "" - job.save(update_fields=["status", "error_message"]) - - task_fn.delay() - return Response({"status": "Job queued", "task": task_name}) - - @action(detail=True, methods=["patch"]) - def update_job_schedule(self, request: Request, pk=None) -> Response: - try: - job = Job.objects.get(pk=pk) - except Job.DoesNotExist: - return Response(status=status.HTTP_404_NOT_FOUND) - - interval = request.data.get("interval_hours") - if interval is not None: - job.interval_hours = int(interval) - job.save(update_fields=["interval_hours"]) - - _sync_periodic_task(job) - return Response(JobSerializer(job).data) - - @action(detail=False, methods=["post"]) - def merge_flavors(self, request: Request) -> Response: - keep_id = request.data.get("keep_id") - remove_id = request.data.get("remove_id") - - if not keep_id or not remove_id: - return Response( - {"error": "Both keep_id and remove_id are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - try: - keep_flavor = Flavor.objects.get(pk=keep_id) - remove_flavor = Flavor.objects.get(pk=remove_id) - except Flavor.DoesNotExist: - return Response( - {"error": "One or both flavors not found"}, status=status.HTTP_404_NOT_FOUND - ) - - if keep_flavor.category_id != remove_flavor.category_id: - return Response( - {"error": "Flavors must belong to the same category"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - merge_flavors(keep_flavor, remove_flavor) - return Response({"status": "Flavors merged successfully"}) - - @action(detail=False, methods=["post"]) - def send_test_email(self, request: Request) -> Response: - me = current_user(request) - try: - send_mail( - "Holy Flavors Admin Test Email", - (f"This is a test email sent to {me.email} from the Holy Flavors Admin Interface."), - settings.DEFAULT_FROM_EMAIL, - [me.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) - return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) diff --git a/backend/api/views/auth.py b/backend/api/views/auth.py deleted file mode 100644 index f09f4a6..0000000 --- a/backend/api/views/auth.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Cookie-backed JWT auth views. - -`CookieTokenObtainPairView` and `CookieTokenRefreshView` move the JWT pair into -httpOnly cookies on the response so the frontend never has tokens in JS-readable -storage. Refresh accepts the token from the cookie *or* the legacy request body. -`CookieLogoutView` clears both cookies and blacklists the refresh token. -""" - -from __future__ import annotations - -from django.conf import settings -from rest_framework import status -from rest_framework.permissions import AllowAny -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.views import APIView -from rest_framework_simplejwt.exceptions import TokenError -from rest_framework_simplejwt.tokens import RefreshToken -from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView - -from api.utils.cookie_auth import clear_jwt_cookies, set_jwt_cookies - - -class CookieTokenObtainPairView(TokenObtainPairView): - def post(self, request: Request, *args, **kwargs): - response = super().post(request, *args, **kwargs) - if response.status_code == status.HTTP_200_OK: - access = response.data.pop("access", None) - refresh = response.data.pop("refresh", None) - set_jwt_cookies(response, access, refresh) - response.data = {"status": "ok"} - return response - - -class CookieTokenRefreshView(TokenRefreshView): - def post(self, request: Request, *args, **kwargs): - if "refresh" not in request.data: - cookie_refresh = request.COOKIES.get(settings.JWT_AUTH_COOKIE_REFRESH) - if cookie_refresh: - # request.data is a QueryDict / dict — make a mutable copy. - data = request.data.copy() if hasattr(request.data, "copy") else dict(request.data) - data["refresh"] = cookie_refresh - request._full_data = data # type: ignore[attr-defined] - response = super().post(request, *args, **kwargs) - if response.status_code == status.HTTP_200_OK: - access = response.data.pop("access", None) - refresh = response.data.pop("refresh", None) - set_jwt_cookies(response, access, refresh) - response.data = {"status": "ok"} - return response - - -class CookieLogoutView(APIView): - permission_classes = [AllowAny] - - def post(self, request: Request) -> Response: - refresh = request.COOKIES.get(settings.JWT_AUTH_COOKIE_REFRESH) - if refresh: - try: - RefreshToken(refresh).blacklist() - except TokenError: - pass - response = Response({"status": "ok"}) - clear_jwt_cookies(response) - return response diff --git a/backend/api/views/banner.py b/backend/api/views/banner.py deleted file mode 100644 index d8d94db..0000000 --- a/backend/api/views/banner.py +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index ef26034..0000000 --- a/backend/api/views/flavor.py +++ /dev/null @@ -1,106 +0,0 @@ -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, -) -from api.utils.auth import current_user - - -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 = current_user(request).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 deleted file mode 100644 index 5f60e28..0000000 --- a/backend/api/views/rating.py +++ /dev/null @@ -1,126 +0,0 @@ -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.utils.auth import current_user -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): - me = current_user(self.request) - flavor = serializer.validated_data["flavor"] - if Rating.objects.filter(user=me, flavor=flavor).exists(): - raise serializers.ValidationError("You have already rated this flavor.") - rating = serializer.save(user=me) - if rating.comment: - parse_mentions(rating.comment, me, 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: - me = current_user(request) - followed_users = me.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: - me = current_user(request) - 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=me, rating=rating, text=text) - - if rating.user != me: - Notification.objects.create( - recipient=rating.user, - actor=me, - notification_type="reply", - rating=rating, - reply=reply, - ) - - parse_mentions(text, me, 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): - me = current_user(self.request) - reply = self.get_object() - if reply.user != me and not me.is_superuser: - raise PermissionDenied("You cannot edit this reply.") - new_reply = serializer.save() - parse_mentions(new_reply.text, me, rating=new_reply.rating, reply=new_reply) - - def perform_destroy(self, instance): - me = current_user(self.request) - if instance.user != me and not me.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 deleted file mode 100644 index 28007ab..0000000 --- a/backend/api/views/support.py +++ /dev/null @@ -1,87 +0,0 @@ -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 -from api.utils.auth import current_user - - -class TicketViewSet(viewsets.ModelViewSet): - serializer_class = TicketSerializer - permission_classes = [permissions.IsAuthenticated] - - def get_queryset(self): - if getattr(self, "swagger_fake_view", False): - return Ticket.objects.none() - me = current_user(self.request) - if me.is_superuser: - return ( - Ticket.objects.all() - .prefetch_related("messages", "messages__user") - .order_by("-updated_at") - ) - return ( - Ticket.objects.filter(user=me) - .prefetch_related("messages", "messages__user") - .order_by("-updated_at") - ) - - def perform_create(self, serializer): - me = current_user(self.request) - ticket = serializer.save(user=me) - admins = User.objects.filter(is_superuser=True).exclude(pk=me.pk) - for admin in admins: - Notification.objects.create( - recipient=admin, - actor=me, - notification_type="ticket_new", - ticket=ticket, - ) - - @action(detail=True, methods=["post"]) - def add_message(self, request: Request, pk=None) -> Response: - me = current_user(request) - 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=me, text=text) - - if me.is_superuser: - if ticket.user != me: - Notification.objects.create( - recipient=ticket.user, - actor=me, - notification_type="ticket_reply", - ticket=ticket, - ) - else: - admins = User.objects.filter(is_superuser=True).exclude(pk=me.pk) - for admin in admins: - Notification.objects.create( - recipient=admin, - actor=me, - 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 deleted file mode 100644 index f66f272..0000000 --- a/backend/api/views/user.py +++ /dev/null @@ -1,562 +0,0 @@ -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 -from api.utils.auth import current_user - - -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: - me = current_user(request) - following = me.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: - me = current_user(request) - 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=me, - text=text, - ) - - if profile_owner != me: - Notification.objects.create( - recipient=profile_owner, - actor=me, - 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: - me = current_user(request) - 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 != me and not me.is_superuser and profile_owner != me: - 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: - me = current_user(request) - log_user_ip(me, request) - return Response(self.get_serializer(me).data) - - @action(detail=False, methods=["patch"]) - def update_preferences(self, request: Request) -> Response: - user = current_user(request) - theme = request.data.get("theme") - language = request.data.get("language") - drawer_anchor = request.data.get("drawer_anchor") - 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 drawer_anchor in dict(User.DRAWER_ANCHOR_CHOICES): - user.drawer_anchor = drawer_anchor - 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, - "drawer_anchor": user.drawer_anchor, - "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 = current_user(request) - 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 = current_user(request) - 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 = current_user(request) - 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: - me = current_user(request) - user_to_follow = self.get_object() - if user_to_follow == me: - return Response( - {"error": "You cannot follow yourself"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - if not me.following.filter(pk=user_to_follow.pk).exists(): - me.following.add(user_to_follow) - Notification.objects.create( - recipient=user_to_follow, - actor=me, - notification_type="follow", - ) - - return Response({"status": "followed"}) - - @action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated]) - def unfollow(self, request: Request, pk=None) -> Response: - me = current_user(request) - user_to_unfollow = self.get_object() - me.following.remove(user_to_unfollow) - return Response({"status": "unfollowed"}) - - @action(detail=False, methods=["post"]) - def confirm_email(self, request: Request) -> Response: - user = current_user(request) - 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 - ) - if not user.pending_email: - return Response( - {"error": "No pending email change"}, 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 = current_user(request) - 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 = current_user(request) - 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 = current_user(request) - 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): - if getattr(self, "swagger_fake_view", False): - return Notification.objects.none() - me = current_user(self.request) - return me.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: - me = current_user(request) - me.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/entrypoint.sh b/backend/entrypoint.sh deleted file mode 100755 index a3ce13e..0000000 --- a/backend/entrypoint.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/sh - -set -e - -# Worker/beat share the backend image but must not re-run migrate/seed on every -# restart. Set SKIP_INIT=true on those services. Only the primary backend -# container runs the init block below. -if [ "${SKIP_INIT:-false}" = "true" ]; then - echo "SKIP_INIT=true — skipping migrations/seed." - if [ "$#" -gt 0 ]; then - echo "Executing: $@" - exec "$@" - fi - echo "No command given with SKIP_INIT=true; exiting." - exit 0 -fi - -echo "Applying database migrations..." -python manage.py migrate --noinput - -echo "Collecting static files..." -python manage.py collectstatic --no-input - -mkdir -p media/flavors - -# One-shot seed jobs. These should run on image first boot, not on every -# restart. A marker file on the bind-mounted volume gates them. -SEED_MARKER="/app/.seeded" -if [ ! -f "$SEED_MARKER" ]; then - echo "First boot — running initial seed commands..." - python manage.py sync_flavors || echo "sync_flavors failed (non-fatal on first boot)" - python manage.py cleanup_duplicates || echo "cleanup_duplicates failed (non-fatal)" - python manage.py seed_legacy_flavors || echo "seed_legacy_flavors failed (non-fatal)" - python manage.py seed_banners || echo "seed_banners failed (non-fatal)" - touch "$SEED_MARKER" -else - echo "Seed marker present — skipping seed commands. Trigger them from /admin if needed." -fi - -if [ "$#" -gt 0 ]; then - echo "Executing custom command: $@" - exec "$@" -fi - -echo "Starting Gunicorn..." -exec gunicorn holy_backend.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 120 diff --git a/backend/holy_backend/__init__.py b/backend/holy_backend/__init__.py deleted file mode 100644 index f7a0c99..0000000 --- a/backend/holy_backend/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 7da6dde..0000000 --- a/backend/holy_backend/asgi.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -ASGI config for holy_backend project. - -It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/ -""" - -import os - -from django.core.asgi import get_asgi_application - -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 deleted file mode 100644 index 6ad75c8..0000000 --- a/backend/holy_backend/celery.py +++ /dev/null @@ -1,15 +0,0 @@ -"""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/__init__.py b/backend/holy_backend/settings/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/holy_backend/settings/base.py b/backend/holy_backend/settings/base.py deleted file mode 100644 index df6cb2f..0000000 --- a/backend/holy_backend/settings/base.py +++ /dev/null @@ -1,206 +0,0 @@ -""" -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", - } -} - -DJANGO_CACHE_URL = os.environ.get("DJANGO_CACHE_URL", "redis://redis:6379/1") - -CACHES = { - "default": { - "BACKEND": "django.core.cache.backends.redis.RedisCache", - "LOCATION": DJANGO_CACHE_URL, - } -} - -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": [ - "api.utils.cookie_auth.CookieJWTAuthentication", - ], - "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",), -} - -# httpOnly-cookie JWT config. The Authorization header path is kept as a -# fallback during the rollout — once all clients use cookies it can be removed. -JWT_AUTH_COOKIE_ACCESS = "access_token" -JWT_AUTH_COOKIE_REFRESH = "refresh_token" -JWT_AUTH_COOKIE_SECURE = False # prod.py overrides to True -JWT_AUTH_COOKIE_SAMESITE = "Lax" -JWT_AUTH_COOKIE_PATH = "/" - -CORS_ALLOWED_ORIGINS = get_env_list( - "CORS_ALLOWED_ORIGINS", - [f"https://{DOMAIN}", "http://localhost:5173", "http://127.0.0.1:5173"], -) -CORS_ALLOW_CREDENTIALS = True - -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" -CELERY_TASK_ACKS_LATE = True -CELERY_TASK_REJECT_ON_WORKER_LOST = True -CELERY_WORKER_PREFETCH_MULTIPLIER = 1 -CELERY_RESULT_EXPIRES = 3600 diff --git a/backend/holy_backend/settings/dev.py b/backend/holy_backend/settings/dev.py deleted file mode 100644 index 03dcc93..0000000 --- a/backend/holy_backend/settings/dev.py +++ /dev/null @@ -1,18 +0,0 @@ -"""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 deleted file mode 100644 index ee45337..0000000 --- a/backend/holy_backend/settings/prod.py +++ /dev/null @@ -1,23 +0,0 @@ -"""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 -JWT_AUTH_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 deleted file mode 100644 index 28bad1f..0000000 --- a/backend/holy_backend/urls.py +++ /dev/null @@ -1,46 +0,0 @@ -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 TokenVerifyView - -from api.views.auth import CookieLogoutView, CookieTokenObtainPairView, CookieTokenRefreshView - - -def health_check(request): - return HttpResponse("OK", content_type="text/plain") - - -# Clean the admin path -admin_url = os.environ.get("ADMIN_URL", "admin").strip("/") - -urlpatterns = [ - # 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")), - # 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/auth/token/", CookieTokenObtainPairView.as_view(), name="token_obtain_pair"), - path("api/auth/token/refresh/", CookieTokenRefreshView.as_view(), name="token_refresh"), - path("api/auth/token/verify/", TokenVerifyView.as_view(), name="token_verify"), - path("api/auth/logout/", CookieLogoutView.as_view(), name="auth_logout"), - # Legacy alias: old DRF TokenAuth endpoint now sets cookies just like the - # canonical login route so clients relying on POST /api/token/ keep working. - path("api/token/", CookieTokenObtainPairView.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 deleted file mode 100644 index 8fec447..0000000 --- a/backend/holy_backend/wsgi.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -WSGI config for holy_backend project. - -It exposes the WSGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ -""" - -import os - -from django.core.wsgi import get_wsgi_application - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev") - -application = get_wsgi_application() diff --git a/backend/manage.py b/backend/manage.py deleted file mode 100755 index b8ed64e..0000000 --- a/backend/manage.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/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.dev") - try: - from django.core.management import execute_from_command_line - except ImportError as exc: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" - ) from exc - execute_from_command_line(sys.argv) - - -if __name__ == "__main__": - main() diff --git a/backend/pyproject.toml b/backend/pyproject.toml deleted file mode 100644 index 24d5a8a..0000000 --- a/backend/pyproject.toml +++ /dev/null @@ -1,49 +0,0 @@ -[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 deleted file mode 100644 index 85b0311..0000000 --- a/backend/requirements-dev.txt +++ /dev/null @@ -1,14 +0,0 @@ --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 deleted file mode 100644 index 13984fb..0000000 --- a/backend/requirements.txt +++ /dev/null @@ -1,16 +0,0 @@ -django>=5.0 -djangorestframework>=3.14.0 -django-cors-headers>=4.3.0 -django-filter>=23.5 -markdown>=3.5 -Pillow>=10.2.0 -drf-spectacular>=0.27.0 -whitenoise>=6.6.0 -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/community-redesign.md b/community-redesign.md deleted file mode 100644 index 574d3d8..0000000 --- a/community-redesign.md +++ /dev/null @@ -1,145 +0,0 @@ -# Community Tab Redesign - -The Community tab is the oldest part of the site and doesn't fit the robust, -professional design of the rest. This document tracks the redesign. - -## Starting state - -`frontend/src/pages/CommunityFeed.tsx` — a paginated feed of ratings **from -followed users only**, with a single sidebar card ("Circle's Top Rated"). Replies -are hidden behind a per-card collapse. Loading is a bare center spinner. - -### Core problems - -- **Cold start is dead.** No follows → empty feed → no reason to return. The - backend `ratings/feed/` is gated on the follow graph and returns nothing for - users who follow no one. -- **Single content type.** Only ratings. No new-flavor drops, profile comments, - follow events, or milestones. -- **No engagement loop.** No reactions/likes. Replies buried in a collapse. The - feed is a read-only wall. -- **No filtering/sort.** Can't sort recent/top or filter by category, even though - the payload already carries `category_name`/`category_slug`. -- **Sparse sidebar.** One card wastes the 4-column rail. - ---- - -## Phase 1 — frontend-only (DONE) - -No backend changes. Reuses the existing `GET /api/ratings/recent/` endpoint -(global, AllowAny, 10 newest ratings with a comment). - -- **Following | Discover tabs.** Discover surfaces the whole community's newest - ratings, so the page is useful even when you follow nobody — fixes the - cold-start hole. Discover is fetched lazily (`enabled: tab === 'discover'`). -- **Skeleton loader** (`FeedSkeleton`) replaces the center spinner — matches the - professional look of the rest of the app. -- **Inline reply preview.** The latest reply shows compact under each card; click - expands the full thread + compose box. Replies feel like conversation, not - archive. -- **Smarter empty states.** Empty Following nudges to Discover (button switches - tab); empty Discover nudges to explore flavors. -- **i18n** EN + DE keys added under `community.*`. - -### Files touched - -- `frontend/src/api/queries/useCommunityFeed.ts` — added `useRecentRatings`. -- `frontend/src/pages/CommunityFeed.tsx` — tabs, skeleton, inline reply preview, - empty-state logic. -- `frontend/src/i18n.ts` — `tabFollowing`, `tabDiscover`, `discoverSubtitle`, - `discoverEmpty`, `discoverEmptyHint`, `browseDiscover` (EN + DE). - ---- - -## Phase 2 — needs backend - -### 1. Reactions (highest engagement lever) — DONE - -Lightweight 👍 / emoji on ratings. Social proof, drives return visits. - -- New `rating_reaction` table (`user_id`, `rating_id`, `kind`, `created_at`; - unique on `user_id + rating_id + kind`). Created in `db.rs::ensure_schema` - (idempotent `CREATE TABLE IF NOT EXISTS` + index on `rating_id`). -- Endpoints: `POST /api/ratings/{id}/react/`, `DELETE /api/ratings/{id}/react/`. - Allow-listed `kind`, rate-limited (60/min/user), idempotent on the unique index. -- `RatingOut` carries `reactions` (kind → count) + `my_reactions` (caller's own). - Threaded through `build_ratings(viewer_id)` via the `OptionalUser` extractor. -- Frontend: `ReactionBar` on each feed card; optimistic toggle, server-reconciled. - -#### Files touched - -- `backend-rs/src/entities/rating_reaction.rs` (new), `entities/mod.rs`. -- `backend-rs/src/db.rs` (table + index), `throttle.rs` (`REACTION` rate), - `dto.rs` (`reactions`/`my_reactions`), `service.rs` (`load_reactions`, - `build_ratings` viewer param), `routes/ratings.rs` (`react`/`unreact`), - plus call-site updates in `routes/users.rs`, `routes/admin.rs`. -- `frontend/src/components/ReactionBar.tsx` (new), - `frontend/src/pages/CommunityFeed.tsx`, `api/queries/useCommunityFeed.ts`, - `frontend/src/i18n.ts`. - -### 2. Discover feed with sort + pagination — DONE - -`recent/` is fixed at 10 and unpaginated. Discover is now a real feed. - -- New `GET /api/ratings/discover/` (AllowAny) accepts `?sort=recent|top`, - `?category=`, `?page=`. Quality bar kept (non-empty comment only). - `top` orders by score desc then recency; category resolves slug → flavor ids. -- Frontend: sticky filter bar — `Recent | Top rated` sort chips + `All` plus a - chip per category (reuses `useCategories`). Discover now paginates like - Following; page resets on tab/sort/category change. - -#### Files touched - -- `backend-rs/src/routes/ratings.rs` (`discover` handler + route). -- `frontend/src/api/queries/useCommunityFeed.ts` (`useDiscoverFeed`), - `frontend/src/api/keys.ts` (`communityDiscover`), - `frontend/src/pages/CommunityFeed.tsx` (filter bar, discover pagination), - `frontend/src/i18n.ts` (`sortRecent`, `sortTop`, `allCategories`). - -### 3. Who-to-follow — DONE - -Sidebar card suggesting active raters the user doesn't follow yet. - -- `GET /api/users/suggested/` — up to 5 most-active raters (by rating count), - excluding self + already-followed. Returns `UserOut` (reuses `build_users`), - re-sorted to the activity ranking after the id-ordered load. -- Frontend: `SuggestedFollows` card (avatar + name + follower count + Follow - button), optimistic — drops the user from the list on follow, reverts on error. - -#### Files touched - -- `backend-rs/src/routes/users.rs` (`suggested` handler + route). -- `frontend/src/api/queries/useCommunityFeed.ts` (`useSuggestedUsers`), - `frontend/src/pages/CommunityFeed.tsx` (`SuggestedFollows` sidebar card), - `frontend/src/i18n.ts` (`whoToFollow`, `follow`, `followFailed`, - `followerCount`). - -### 4. Richer activity types — DONE - -Make the feed alive when friends are quiet. - -- New `GET /api/activity/` (AllowAny, paginated): a unified, newest-first stream - of `new_flavor` drops + `milestone` events. No persistent event table — items - are synthesized on read from `flavor.created_at` and, for milestones, the - timestamp of the rating that crossed a threshold (25/50/100/250/500/1000; - one item per flavor at its highest crossed threshold). Candidate set is bounded - (latest 40 drops + flavors ≥25 ratings), built once and paged in memory. -- Frontend: third **Activity** tab with its own card style (drop vs. milestone - icon), paginated like the others. - -#### Files touched - -- `backend-rs/src/dto.rs` (`ActivityOut`), `service.rs` (`build_activity`), - `routes/activity.rs` (new), `routes/mod.rs` (mount). -- `frontend/src/api/queries/useCommunityFeed.ts` (`useActivityFeed`), - `frontend/src/api/keys.ts` (`communityActivity`), - `frontend/src/pages/CommunityFeed.tsx` (Activity tab + `renderActivityCard`), - `frontend/src/i18n.ts` (`tabActivity`, `activitySubtitle`, `activityEmpty`, - `activityEmptyHint`, `newDropLabel`, `milestoneLabel`, `milestoneText`). - ---- - -## Suggested order - -Phase 2.1 (reactions) and 2.2 (Discover sort/filter) give the most UX lift per -unit of backend work. 2.3 and 2.4 follow. **All of Phase 2 is now complete.** diff --git a/dashboard-redesign.md b/dashboard-redesign.md deleted file mode 100644 index 45e1c02..0000000 --- a/dashboard-redesign.md +++ /dev/null @@ -1,227 +0,0 @@ -# Dashboard Redesign — Audit & Plan - -Scope: the authenticated user dashboard (`frontend/src/pages/Dashboard.tsx`, backend -`GET /api/users/dashboard/` in `backend-rs/src/routes/users.rs:1311`). Goal: cleaner, -more intuitive, more robust UX, plus algorithmic recommendations ("tasters like you -also liked X"). - -Data scale (current, `backend/db.sqlite3`): **13 users · 219 flavors · 331 ratings · -~25 ratings/user**. Sparse and small — everything fits in memory; recommendations can -be computed in-process per request. No Redis/precompute needed yet. - ---- - -## Part 1 — Audit of the current dashboard - -### Architecture / code quality -- **A1. 1039-line monolith.** `Dashboard.tsx` is one component holding the profile - header, stats, two tabs, two filter toolbars, two card lists, and inline - rating-edit + reply editors. Nothing is extracted, nothing is unit-testable in - isolation. -- **A2. Duplicated rating-edit/reply logic.** The edit-score, edit-comment, reply - submit, delete-reply handlers (`Dashboard.tsx:148-185`) re-implement what - `FlavorDetail.tsx` already does. Same bug surface in two places (the Q1 notif-menu - dedup pattern applies here too). -- **A3. ColorThief on every load.** `Dashboard.tsx:109-146` fetches the avatar as a - CORS `Image`, runs `colorthief.getPalette(...,10)` on the main thread, and derives - HSL just to tint a decorative header gradient. Extra dependency, main-thread work, - CORS dependency — all for cosmetics. Already skipped on mobile (`isXs`). - -### Backend / performance -- **A4. Loads every unrated flavor, every hit.** The dashboard handler builds - `missing_flavors` from *all* flavors the user hasn't rated (~200 rows), with - per-flavor aggregate rollups, on every dashboard request - (`users.rs:1340-1378`). No pagination. (P1 already trimmed the *per-rating* payload - here via `include_ratings=false`, but the row count itself is unbounded.) -- **A5. All filter/sort is client-side** over the full arrays (`Dashboard.tsx:187-237`). - Fine at 200 items; breaks down as the catalog grows and blocks server-side - pagination. - -### UX / product -- **A6. Thin stats.** Only two numbers surfaced: `rated_count`, `missing_count` - (`Dashboard.tsx:405-408`). No average score the user gives, no rating distribution, - no favorite category, no recent-activity sense. -- **A7. "Explore" is an undifferentiated dump.** Tab 2 is the full unrated list sorted - by community or circle average. No curation, no "for you", overwhelming, no - pagination, no recommendations. -- **A8. Flat two-tab IA** (My Reviews / Explore New). No personalized landing; the - user's *taste* is never reflected back to them. -- **A9. Rough edges.** `"Please login to view dashboard."` is a hardcoded, - untranslated string (`Dashboard.tsx:255`); several toast messages are hardcoded - English (`'Failed to update review'`, etc.) instead of i18n keys. -- **A10. No recommendations exist** anywhere in the product today. - ---- - -## Part 2 — Recommendation engine (the headline feature) - -"Users with the same taste also like X" = **user-based collaborative filtering (CF)**, -with a popularity/quality fallback for cold-start users. - -### Algorithm (user-based CF) -1. Load the full rating matrix (331 cells — trivial) into memory: `user → {flavor: score}`. -2. For the target user `u`, compute similarity to every other user `v` over their - **co-rated** flavors using **Pearson correlation** (mean-centred, so it cancels out - the fact that some users rate high and some low). -3. **Min-overlap guard:** ignore any `v` sharing fewer than ~3 co-rated flavors — - correlation over 1–2 points is noise. Apply **significance weighting** - `sim *= min(overlap, K)/K` (K≈5) to down-weight thin overlaps. -4. For each candidate flavor `f` the target hasn't rated, predict: - `pred(u,f) = mean_u + Σ_v sim(u,v)·(r_vf − mean_v) / Σ |sim(u,v)|` - over neighbours `v` who rated `f`. Require ≥2 contributing neighbours for confidence. -5. Rank candidates by `pred`, return top-N with metadata: `predicted_score`, - `contributing_neighbours` (the "based on N tasters like you" count), and the - neighbour usernames (optional, for "because Alice & Bob loved it"). - -### Cold-start fallback (user with < ~5 ratings, or no confident neighbours) -Rank unrated flavors by a **Bayesian average** to stop a single 10/10 from topping the -chart: `score = (C·m + Σr) / (C + n)` where `m` = global mean score, `n` = that -flavor's rating count, `C` ≈ 5 (confidence prior). Optionally bias toward categories the -user has already engaged with. - -### Why in-process, on-demand -At 13 users / 331 ratings the whole computation is sub-millisecond. Compute it inside -the recommendations endpoint; add a short per-user cache only if profiling says so. -If the dataset grows orders of magnitude, promote it to a **background job** -(`src/jobs/` already exists) that precomputes per-user recommendations into a table on a -schedule — the engine code stays the same, only the trigger changes. - -### Optional companion: item-based CF -Flavor↔flavor cosine over rating vectors powers "people who liked *this* flavor also -liked…" — useful on `FlavorDetail` too, not just the dashboard. Separate, later phase. - ---- - -## Part 3 — Phased implementation plan - -Each phase is independently shippable and verified (`tsc` + ESLint + vitest on the -frontend; `cargo fmt`/`clippy`/`test` on the backend). - -### Phase 0 — Decompose (no behaviour change) — ✅ DONE -Split the 1039-line `Dashboard.tsx` (now ~115 lines, thin orchestrator) into -`src/pages/dashboard/`: -- `DashboardHeader.tsx` — action bar + profile + stats. -- `MyReviewsTab.tsx` — filter toolbar + list; owns its filter/sort/category state. -- `MyReviewCard.tsx` — one own-review card; **owns its own** edit/reply/expand state - + mutation calls (replaces the Dashboard-level state maps keyed by rating id). -- `ExploreTab.tsx` — filter toolbar + grid; owns its own filter state. -- `usePalette.ts` — the ColorThief effect, isolated (deleted in Phase 3). - -Also fixed A9's hardcoded login string → `t('dashboard.loginRequired')` (EN+DE). -`tsc` clean, ESLint 0 errors (1 pre-existing setState-in-effect warning in the -soon-to-be-deleted `usePalette`), 27 tests pass, `vite build` clean. - -**Deviation from original plan:** did *not* force a single shared `ReviewCard` -across Dashboard + `FlavorDetail`. On inspection the two cards are structurally -different — the dashboard card is flavor-centric (one user, many flavors), the -detail card is user-centric (one flavor, many users). Unifying would over-couple two -genuinely different layouts. The cross-file A2 dedup is reduced to a possible future -extraction of just the shared reply-thread atom, tracked but not forced. - -### Phase 1 — Enrich the backend payload — ✅ DONE (stats); payload-slim deferred -- Added a **`stats` block** to `DashboardOut` (`dto.rs`): `average_score`, - `score_distribution` (1–10 histogram, always all 10 keys), `favorite_category` - (`{name, slug, count}`, deterministic tie-break by slug), `ratings_this_month` - (current UTC month), `category_count`. Computed in the `dashboard` handler - (`users.rs`) by a pure `dashboard_stats()` helper over the already-loaded ratings — - **no new queries**. 3 unit tests (empty / aggregate / tie-break). `cargo fmt` + - `clippy` clean, 23 backend tests pass. -- Frontend: extended `DashboardData` with `DashboardStats`/`FavoriteCategory`; - `DashboardHeader` now shows Avg Score + This Month tiles and a "Top category" line - (i18n EN+DE). `tsc` + ESLint (0 errors) clean, 27 tests, build clean. - -**Sequencing change (deliberate):** the planned payload slim — dropping the -all-unrated `missing_flavors` dump (A4) — is **deferred to Phase 3**, where the -Explore tab that still consumes it is removed. Dropping it now would break a live UI -mid-refactor. Phase 1 stayed purely additive. - -### Phase 2 — Recommendations engine — ✅ DONE -- New pure module `backend-rs/src/recommend.rs` (no DB → unit-testable): user-based CF - with Pearson similarity (mean-centred), `MIN_OVERLAP=3`, significance weighting - `min(overlap,5)/5`, positive-correlation-only neighbours, `MIN_NEIGHBOURS=2`, - prediction clamped to 1–10. Cold start (`<5` own ratings, or no confident CF - candidates) → Bayesian-popularity fallback (`C=5`). Deterministic ordering (score → - neighbours → id). 3 unit tests: cold-start, CF-finds-shared-pick (with opposite-taste - decoy filtered), exclude-already-rated + limit. -- New endpoint `GET /api/users/recommendations/` (`users.rs`) → `Vec` - (`dto.rs`): card fields + `predicted_score`, `contributing_neighbours`, `reason` - (`"cf"`/`"popular"`). **Count only — no neighbour names** (locked decision). Loads - the full rating matrix once, scores in-process, resolves ids via `build_flavors` - (`include_ratings=false`). `cargo fmt`/`clippy` clean, 26 backend tests pass. -- Frontend plumbing: `Recommendation` type + `useRecommendations()` query hook - (key `recommendations`, 5 min staleTime) + `queryKeys.recommendations()`. UI wiring - is Phase 3. `tsc` + ESLint (0 errors) clean, 27 tests pass. - -### Phase 3 — UI redesign ("For You" first) — ✅ DONE -- **Backend payload slim (the deferred A4):** `DashboardOut` no longer ships - `missing_flavors` (the full unrated `Vec` with per-flavor aggregates). - The handler now computes `missing_count` from flavor ids alone — no `build_flavors` - pass over ~200 rows, no extra category/rating/user queries. `cargo fmt`/`clippy` - clean, 26 backend tests pass. -- **Frontend IA:** removed the two-tab layout. Dashboard is now a single page — - `DashboardHeader` (hero stats) → `RecommendationsSection` ("Tasters like you - loved", count-only reason chips, skeleton while loading, hidden when empty) → "My - Reviews" list. Deleted `ExploreTab.tsx`. -- **Dropped ColorThief from the dashboard (A3):** `DashboardHeader` uses a - theme-derived gradient; deleted `usePalette.ts`. (The npm dep stays — `PublicProfile` - still uses ColorThief; that's out of dashboard scope.) -- New components: `RecommendationCard.tsx` (image + predicted-score badge + reason - chip), `RecommendationsSection.tsx` (wraps `useRecommendations`). -- i18n EN+DE: `recHeading`, `recReasonCf`, `recReasonPopular`, `predictedForYou` (plus - the earlier `loginRequired`/`avgScore`/`thisMonth`/`favoriteCategory`). -- `tsc` + ESLint (0 warnings — the old `usePalette` setState-in-effect warning is gone - with the file), 27 tests, `vite build` clean. - -**Scope note:** the plan's separate "curated rows" (top community / newest) are -covered by the recommendation engine's popularity fallback — a cold or -rated-everything user still gets popularity-ranked picks in the same carousel — so a -distinct curated row was not added. Browsing the full catalog now lives on the main -flavor list, as decided. Leftover unused i18n keys from the old Explore tab -(`searchMissing`, `communityRating`, `circleRating`, `allRated`, `exploreNew`) were -left in place (harmless); can be swept later. - -### Phase 4 — Item-based "similar flavors" — ✅ DONE -- **Engine:** added `similar_flavors()` to `backend-rs/src/recommend.rs` — **adjusted - cosine** over the rating matrix (each user's scores mean-centred to cancel rater - bias), positive-correlation-only, `MIN_CORATERS=2`, deterministic ordering - (similarity → co-raters → id). Returns `SimilarFlavor { flavor_id, similarity, - co_raters }`. 3 unit tests (co-liked / min-coraters guard / unknown target). -- **Endpoint:** `GET /api/flavors/{id}/similar/` (`routes/flavors.rs`, `SIMILAR_LIMIT=8`) - → `Vec` (`dto.rs`: card fields + `similarity` + `co_raters`). - 404 if the flavor doesn't exist; **empty list** (not 404) when too few co-ratings to - compute. Loads the matrix once, resolves ids via `build_flavors` (no nested - ratings/replies). `cargo fmt`/`clippy` clean, **29 backend tests pass**. -- **Frontend:** `SimilarFlavor` type + `queryKeys.similarFlavors(id)` + - `useSimilarFlavors()` hook (public, 5 min staleTime). New - `pages/flavors/components/SimilarFlavorsSection.tsx` — "People who liked this also - liked" grid (compact cards: image + avg-rating badge + status + "N rated both" - chip), skeleton while loading, **hidden when empty**. Wired into `FlavorDetail.tsx` - below the ratings grid. i18n EN+DE (`similarHeading`, `similarCoRaters`, - `similarTooltip`). `tsc` + ESLint (0 warnings), 27 tests, `vite build` clean. - -**Deferred (not built):** promoting recommendations to a scheduled precompute job — -unnecessary at current scale (sub-ms in-process); the engine code is unchanged when -that day comes, only the trigger moves. Tracked, not done. - ---- - -## Resolved decisions (user) -- **Recommendation reason = count only.** Show "N tasters like you loved this"; do not - name neighbours (no privacy exposure). The endpoint still returns - `contributing_neighbours` as a number; neighbour usernames are not surfaced. -- **Drop ColorThief.** The avatar-derived header gradient is replaced by a - theme-derived gradient — removes the dependency and the main-thread work (A3). No - Web Worker needed. -- **Collapse "Explore".** The dashboard no longer hosts a full browsable unrated - catalog. Dashboard = "For You" (recommendations) + curated rows + My Reviews. - General browsing lives on the main flavor list. This drops the all-unrated-flavors - load (A4) entirely from the dashboard payload — a large simplification. - -### Plan adjustments from these decisions -- **Phase 1** no longer needs a paginated "browse unrated" view in the dashboard; it - just adds the stats block and the bounded curated rows. The `missing_flavors` dump - is removed from the dashboard response outright. -- **Phase 2** endpoint shape: `[{ ...flavor fields, predicted_score, - contributing_neighbours, reason_key }]` — no neighbour usernames. -- **Phase 3** IA becomes: hero stats → "Tasters like you loved…" carousel → curated - rows → My Reviews. The second tab ("Explore New") is removed. diff --git a/backend/banners/fireflies.json b/data/banners/fireflies.json similarity index 100% rename from backend/banners/fireflies.json rename to data/banners/fireflies.json diff --git a/backend/banners/generative-flow.json b/data/banners/generative-flow.json similarity index 100% rename from backend/banners/generative-flow.json rename to data/banners/generative-flow.json diff --git a/backend/banners/generative-web.json b/data/banners/generative-web.json similarity index 100% rename from backend/banners/generative-web.json rename to data/banners/generative-web.json diff --git a/backend/banners/hextech-corruption.json b/data/banners/hextech-corruption.json similarity index 100% rename from backend/banners/hextech-corruption.json rename to data/banners/hextech-corruption.json diff --git a/backend/banners/hextech.json b/data/banners/hextech.json similarity index 100% rename from backend/banners/hextech.json rename to data/banners/hextech.json diff --git a/backend/banners/matrix.json b/data/banners/matrix.json similarity index 100% rename from backend/banners/matrix.json rename to data/banners/matrix.json diff --git a/backend/banners/nebula.json b/data/banners/nebula.json similarity index 100% rename from backend/banners/nebula.json rename to data/banners/nebula.json diff --git a/docker-compose.yml b/docker-compose.yml index c04729c..620d2f3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,19 +20,19 @@ services: - DOMAIN=${DOMAIN:-localhost} - BIND_ADDR=0.0.0.0:8001 - SECRET_KEY=${SECRET_KEY} - - DATABASE_URL=sqlite:///srv/backend/db.sqlite3?mode=rw - - MEDIA_ROOT=/srv/backend/media + - DATABASE_URL=sqlite:///srv/data/db.sqlite3?mode=rw + - MEDIA_ROOT=/srv/data/media - MEDIA_URL=media/ - - BANNERS_DIR=/srv/backend/banners + - BANNERS_DIR=/srv/data/banners - LEGACY_DIR=/srv/legacy - - BACKUP_DIR=/srv/backend/backups + - BACKUP_DIR=/srv/data/backups - FRONTEND_URL=${FRONTEND_URL:-http://${DOMAIN:-localhost}} - JWT_AUTH_COOKIE_SECURE=${JWT_AUTH_COOKIE_SECURE:-true} # Rust owns api_job scheduling (Celery beat retired). - ENABLE_SCHEDULER=${RUST_ENABLE_SCHEDULER:-true} - RUST_LOG=${RUST_LOG:-info,holy_backend_rs=info,sea_orm=warn} volumes: - - ./backend:/srv/backend + - ./data:/srv/data - ./legacy:/srv/legacy healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8001/health/"] @@ -62,7 +62,7 @@ services: volumes: # Serve /media/ straight from the shared mount (read-only) so media is # independent of the backend being up. - - ./backend/media:/usr/share/nginx/media:ro + - ./data/media:/usr/share/nginx/media:ro depends_on: backend-rs: condition: service_healthy diff --git a/frontend/package.json b/frontend/package.json index d0f7cf1..54d1c76 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,8 +13,8 @@ "test": "vitest run", "test:watch": "vitest", "preview": "vite preview", - "schema:export": "curl -fsS http://localhost:8001/api/schema/ -o ../backend/schema.yaml", - "openapi:gen": "openapi-typescript ../backend/schema.yaml -o src/api/schema.d.ts", + "schema:export": "curl -fsS http://localhost:8001/api/schema/ -o ../schema.yaml", + "openapi:gen": "openapi-typescript ../schema.yaml -o src/api/schema.d.ts", "openapi:sync": "npm run schema:export && npm run openapi:gen" }, "dependencies": { diff --git a/backend/schema.yaml b/schema.yaml similarity index 100% rename from backend/schema.yaml rename to schema.yaml diff --git a/scripts/backup.sh b/scripts/backup.sh index 4d11e09..af61c02 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -12,9 +12,9 @@ cd "$ROOT_DIR" TIMESTAMP=$(date +%Y%m%d_%H%M%S) BACKUP_NAME="full_snapshot_$TIMESTAMP" -TEMP_DIR="backend/backups/$BACKUP_NAME" -FINAL_DEST="backend/backups" -DB_SRC="backend/db.sqlite3" +TEMP_DIR="data/backups/$BACKUP_NAME" +FINAL_DEST="data/backups" +DB_SRC="data/db.sqlite3" echo "========================================" echo " Starting Full Project Backup... " @@ -38,7 +38,7 @@ fi # 2. Media. echo "Backing up media..." -cp -r backend/media "$TEMP_DIR/media" +cp -r data/media "$TEMP_DIR/media" # 3. Compress. echo "Compressing..."