removed legacy backend

This commit is contained in:
nvrl committed 2026-06-25 19:37:38 +02:00
1 parent 5ff5da2aac
commit 4f285e589f
121 files changed
+45 -5886

No files matched your search

+6 -10
View File
@@ -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/
+9 -9
View File
@@ -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`
+2 -2
View File
@@ -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
+8 -12
View File
@@ -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
+8 -9
View File
@@ -23,15 +23,14 @@ pub struct Config {
/// can't use a wildcard, so this is an explicit allow-list.
pub cors_allowed_origins: Vec<String>,
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.
-35
View File
@@ -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/
-30
View File
@@ -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=<your 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"]
View File
Whitespace-only changes.
-44
View File
@@ -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)
-5
View File
@@ -1,5 +0,0 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
name = "api"
-20
View File
@@ -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
@@ -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}")
)
@@ -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."))
@@ -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")
@@ -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
@@ -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}")
)
@@ -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_<slug>/00_<hash>.<ext>. 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.")
)
@@ -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/<dir_slug>/<i>_<hash>.<ext>. 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}"
)
)
-84
View File
@@ -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')},
},
),
]
@@ -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),
),
]
@@ -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),
),
]
@@ -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),
),
]
@@ -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),
),
]
-28
View File
@@ -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'],
},
),
]
@@ -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),
),
]
@@ -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),
),
]
@@ -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),
),
]
@@ -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/'),
),
]
@@ -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'),
),
]
@@ -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),
),
]
@@ -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),
),
]
@@ -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'],
},
),
]
@@ -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')},
},
),
]
@@ -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),
),
]
@@ -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'],
},
),
]
@@ -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'},
),
]
@@ -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']},
),
]
@@ -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),
),
]
@@ -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),
),
]
@@ -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),
),
]
-26
View File
@@ -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)),
],
),
]
@@ -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),
]
@@ -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),
),
]
@@ -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.'),
),
]
@@ -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),
),
]
@@ -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',
},
),
]
@@ -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),
),
]
@@ -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),
),
]
@@ -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),
),
]
@@ -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),
),
]
@@ -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),
),
]
@@ -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),
),
]
View File
Whitespace-only changes.
-22
View File
@@ -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",
]
-53
View File
@@ -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
-30
View File
@@ -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}"
-45
View File
@@ -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"]
-29
View File
@@ -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"]
-78
View File
@@ -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})"
-77
View File
@@ -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"]
-33
View File
@@ -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",
]
-23
View File
@@ -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
-89
View File
@@ -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
-54
View File
@@ -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
-64
View File
@@ -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
-40
View File
@@ -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)
-81
View File
@@ -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",
]
-96
View File
@@ -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()]
View File
Whitespace-only changes.
-54
View File
@@ -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"])
-14
View File
@@ -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)
-29
View File
@@ -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,
)
-44
View File
@@ -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
-107
View File
@@ -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,
)
View File
Whitespace-only changes.
-60
View File
@@ -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, "")
-45
View File
@@ -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)
-127
View File
@@ -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)
-19
View File
@@ -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)
-82
View File
@@ -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())
-134
View File
@@ -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": "<p>Syrup desc</p>",
"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)
-19
View File
@@ -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)),
]
View File
Whitespace-only changes.
-26
View File
@@ -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
-72
View File
@@ -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)
-20
View File
@@ -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",
]
-5
View File
@@ -1,5 +0,0 @@
from rest_framework.pagination import PageNumberPagination
class FeedPagination(PageNumberPagination):
page_size = 10
-224
View File
@@ -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)
-65
View File
@@ -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
-65
View File
@@ -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"})
-106
View File
@@ -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)
-126
View File
@@ -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()
-87
View File
@@ -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)
-562
View File
@@ -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<username>[^/.]+)",
)
def public_profile(self, request: Request, username=None) -> Response:
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
return Response({"error": "User not found"}, status=status.HTTP_404_NOT_FOUND)
ratings = user.ratings.select_related("flavor", "flavor__category").order_by("-score")
comments = user.profile_comments.select_related("author").order_by("-created_at")
followers = user.followers.all()
following = user.following.all()
return Response(
{
"id": user.id,
"username": user.username,
"theme": user.theme,
"avatar": (request.build_absolute_uri(user.avatar.url) if user.avatar else None),
"following_count": following.count(),
"followers_count": followers.count(),
"is_following": (
request.user.following.filter(pk=user.pk).exists()
if request.user.is_authenticated
else False
),
"ratings": RatingSerializer(ratings, many=True, context={"request": request}).data,
"comments": ProfileCommentSerializer(
comments, many=True, context={"request": request}
).data,
"followers": UserSerializer(
followers, many=True, context={"request": request}
).data,
"following": UserSerializer(
following, many=True, context={"request": request}
).data,
}
)
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated])
def add_comment(self, request: Request, pk=None) -> Response:
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<comment_id>[^/.]+)",
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"})
-46
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
from holy_backend.celery import app as celery_app
__all__ = ("celery_app",)
-16
View File
@@ -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()
-15
View File
@@ -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()
Whitespace-only changes.
-206
View File
@@ -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
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more