Merge pull request 'feature/refactor' (#1) from feature/refactor into main

Reviewed-on: nvrl/Holy-Flavor-Rater#1
This commit is contained in:
nvrl committed 2026-05-08 22:56:34 +02:00
commit 86e5b18bd3
204 files changed
+24451 -7858

No files matched your search

+23 -1
View File
@@ -2,15 +2,37 @@
# Your primary domain (e.g., example.com). Used to derive ALLOWED_HOSTS, CORS, and CSRF settings.
DOMAIN="localhost"
# Set to a long, random string in production (e.g. 50+ chars)
# Set to a long, random string in production (e.g. 50+ chars). REQUIRED in prod.
SECRET_KEY="django-insecure-replace-this-in-production"
# Which settings module to load. Use holy_backend.settings.dev locally,
# holy_backend.settings.prod in production (Docker compose sets this).
DJANGO_SETTINGS_MODULE=holy_backend.settings.dev
# Set to "false" in production for security and performance
DEBUG=true
# Custom URL for the Django Admin interface (e.g., my-secret-admin/)
ADMIN_URL="admin"
# --- Celery / Redis ---
# Broker and result backend for background jobs. Docker compose defaults to the bundled redis service.
# DB 0 is used by Celery; DB 1 is used by Django cache (see DJANGO_CACHE_URL below).
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0
# Run tasks synchronously (useful for local dev or CI). Unset in production.
# CELERY_TASK_ALWAYS_EAGER=true
# Django cache backend URL. Shares the redis instance on a different DB number.
DJANGO_CACHE_URL=redis://redis:6379/1
# Flower (Celery monitoring UI) — exposed on :5555. Format: "user:password".
# CHANGE BEFORE DEPLOY.
FLOWER_BASIC_AUTH=admin:change-me
# Worker/beat containers set this in docker-compose.yml; do not set globally.
# SKIP_INIT=true
# --- Advanced Networking (Optional, derived from DOMAIN if unset) ---
# ALLOWED_HOSTS=example.com,another-domain.com
# CSRF_TRUSTED_ORIGINS=https://example.com
+37 -11
View File
@@ -20,11 +20,28 @@ jobs:
node-version: '25'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install Dependencies
run: npm install
working-directory: frontend
- name: Build Frontend
- name: Typecheck
run: npm run typecheck
working-directory: frontend
- name: Lint
run: npm run lint
working-directory: frontend
- name: Format Check
run: npm run format:check
working-directory: frontend
- name: Test
run: npm test -- --run
working-directory: frontend
- name: Build
run: npm run build
working-directory: frontend
@@ -32,6 +49,11 @@ jobs:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
env:
SECRET_KEY: "ci-test-key"
DEBUG: "false"
ALLOWED_HOSTS: "localhost"
DJANGO_SETTINGS_MODULE: holy_backend.settings.dev
steps:
- uses: actions/checkout@v4
@@ -40,24 +62,28 @@ jobs:
with:
python-version: '3.14.3'
cache: 'pip'
cache-dependency-path: backend/requirements.txt
cache-dependency-path: backend/requirements-dev.txt
- name: Install Dependencies
run: pip install -r requirements.txt
run: pip install -r requirements-dev.txt
working-directory: backend
- name: Ruff Lint
run: ruff check .
working-directory: backend
- name: Ruff Format Check
run: ruff format --check .
working-directory: backend
- name: Django System Check
run: python manage.py check
working-directory: backend
env:
SECRET_KEY: "ci-test-key"
DEBUG: "false"
ALLOWED_HOSTS: "localhost"
- name: Check Migrations
run: python manage.py makemigrations --check --dry-run
working-directory: backend
env:
SECRET_KEY: "ci-test-key"
DEBUG: "false"
ALLOWED_HOSTS: "localhost"
- name: Run Tests
run: pytest
working-directory: backend
+1
View File
@@ -21,6 +21,7 @@ backend/staticfiles/
backend/media/
backend/django_cache/
backend/backups/
backend/.seeded
# React/Vite build
dist/
+20 -7
View File
@@ -32,9 +32,11 @@ The easiest way to get the full stack (Backend + Frontend + DB) running behind a
```
3. **Start Services:**
```bash
docker-compose up -d --build
docker compose up -d --build
```
*Note: On first startup, the backend will automatically migrate, sync the flavor catalog, and clean up any duplicates.*
*Note: On first startup, the backend runs `migrate` + `collectstatic`, then seeds the flavor catalog, legacy data, and banner configurations **once** (gated by a `/app/.seeded` marker so subsequent restarts don't repeat the work). Re-run individual seeds later from the admin Jobs tab.*
The stack brings up six containers: `redis` (broker + cache), `backend` (Gunicorn), `worker` (Celery worker), `beat` (Celery beat w/ `django_celery_beat.DatabaseScheduler`), `flower` (Celery monitoring UI on `:5555`), and `frontend` (Nginx).
### Option 2: Local Manual Setup (Development)
@@ -51,11 +53,13 @@ The easiest way to get the full stack (Backend + Frontend + DB) running behind a
python manage.py migrate
python manage.py sync_flavors
python manage.py seed_legacy_flavors
python manage.py seed_banners
```
3. **Run Server:**
```bash
python manage.py runserver
```
*By default `holy_backend.settings.dev` is active, which sets `CELERY_TASK_ALWAYS_EAGER=true` — tasks run inline with no broker required. To exercise the full async path locally, start a Redis server and run `celery -A holy_backend worker -l info` + `celery -A holy_backend beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler` in separate terminals.*
#### Frontend
1. **Navigate and Install:**
@@ -76,9 +80,10 @@ The Holy Flavor Rater uses a modern **decoupled architecture**:
### Backend (Django REST Framework)
- **Models:** Custom User model, Flavors (with category constraints), Ratings (with mention parsing), and Dynamic Banners.
- **Automation:** Management commands for periodic Shopify API syncing and database deduplication.
- **Security:** Token-based authentication, CSRF protection, and email-verified account activation.
- **Monitoring:** Integrated `/health/` endpoint for Docker container status.
- **Async jobs:** Celery workers backed by Redis handle Shopify syncs, duplicate cleanup, legacy seeding, banner seeding, database backups, and transactional email. Scheduling is owned by `django_celery_beat` (`PeriodicTask` + `IntervalSchedule`) and editable from the admin UI Jobs tab.
- **Caching & rate-limiting:** Django cache uses Redis (`RedisCache` on DB 1). `django_ratelimit` consumes the same cache.
- **Security:** JWT authentication (SimpleJWT) with refresh-token rotation, CSRF protection, and email-verified account activation.
- **Monitoring:** `/health/` endpoint for the backend; Flower UI on `:5555` for queue + worker state (basic-auth gated).
### Frontend (React 19 + TypeScript)
- **State Management:** React Hooks and local state for high performance.
@@ -101,10 +106,12 @@ bash scripts/install-hooks.sh
```
### Management Commands
Each is also exposed as a Celery task (`api.<name>`) and triggerable from the admin Jobs tab.
- `python manage.py sync_flavors`: Syncs the latest products from the official Holy Energy API.
- `python manage.py cleanup_duplicates`: Merges duplicate entries and maintains rating integrity.
- `python manage.py seed_banners`: Updates procedurally generated banner configurations from JSON.
- `python manage.py backup_db`: Creates a consistent SQLite snapshot.
- `python manage.py seed_legacy_flavors`: Loads retired flavors from `legacy/*.json`.
- `python manage.py seed_banners`: Updates procedurally generated banner configurations from `backend/banners/*.json`.
- `python manage.py backup_db` (`--full` for media too): Creates a consistent SQLite snapshot in `backend/backups/`.
---
@@ -139,9 +146,15 @@ Add this to your `crontab -e` to backup every night at 2:00 AM:
Ensure your `.env` contains:
- `DEBUG=false`
- `DJANGO_SETTINGS_MODULE=holy_backend.settings.prod`
- `SECRET_KEY`: A long random string.
- `ALLOWED_HOSTS`: Your domain (e.g., `holy.narl.io`).
- `FRONTEND_URL`: For one-click email verification links.
- `CELERY_BROKER_URL` / `CELERY_RESULT_BACKEND`: Default `redis://redis:6379/0` works inside the compose network.
- `DJANGO_CACHE_URL`: Default `redis://redis:6379/1` (DB 1 to avoid colliding with celery).
- `FLOWER_BASIC_AUTH`: `user:password` for the Flower UI — **change from the default before exposing**.
Restrict Flower's port `5555` (or reverse-proxy it behind basic-auth + TLS) since it exposes task payloads and worker internals.
## 📄 License
MIT
+7
View File
@@ -13,6 +13,13 @@ 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 . .
+34 -7
View File
@@ -1,17 +1,44 @@
from django import forms
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import User, Category, Flavor, Rating, Banner
from .models import Banner, Category, Flavor, Rating, User
class CustomUserAdmin(UserAdmin):
fieldsets = UserAdmin.fieldsets + (
(None, {'fields': ('theme',)}),
)
add_fieldsets = UserAdmin.add_fieldsets + (
(None, {'fields': ('theme',)}),
fieldsets = UserAdmin.fieldsets + ((None, {"fields": ("theme",)}),)
add_fieldsets = UserAdmin.add_fieldsets + ((None, {"fields": ("theme",)}),)
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)
admin.site.register(Flavor, FlavorAdmin)
admin.site.register(Rating)
admin.site.register(Banner)
+1 -1
View File
@@ -2,4 +2,4 @@ from django.apps import AppConfig
class ApiConfig(AppConfig):
name = 'api'
name = "api"
+5 -2
View File
@@ -1,6 +1,8 @@
import ssl
from django.core.mail.backends.smtp import EmailBackend
from django.conf import settings
from django.core.mail.backends.smtp import EmailBackend
class InsecureEmailBackend(EmailBackend):
"""
@@ -8,9 +10,10 @@ class InsecureEmailBackend(EmailBackend):
Useful for local development or internal SMTP servers with self-signed certificates.
Set EMAIL_SKIP_CERT_VERIFICATION = True in settings to use.
"""
@property
def ssl_context(self):
if getattr(settings, 'EMAIL_SKIP_CERT_VERIFICATION', False):
if getattr(settings, "EMAIL_SKIP_CERT_VERIFICATION", False):
# The most robust way to bypass verification for self-signed certs
context = ssl._create_unverified_context()
return context
+30 -20
View File
@@ -1,35 +1,40 @@
import sqlite3
import os
import shutil
import sqlite3
from datetime import datetime, timedelta
from django.conf import settings
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = 'Creates a safe, consistent backup of the SQLite database and manages rotation.'
help = "Creates a safe, consistent backup of the SQLite database and manages rotation."
def add_arguments(self, parser):
parser.add_argument('--output', type=str, help='Specific output path for the backup file')
parser.add_argument('--retention', type=int, default=7, help='Number of days to keep old backups')
parser.add_argument('--full', action='store_true', help='Create a full archive including media')
parser.add_argument("--output", type=str, help="Specific output path for the backup file")
parser.add_argument(
"--retention", type=int, default=7, help="Number of days to keep old backups"
)
parser.add_argument(
"--full", action="store_true", help="Create a full archive including media"
)
def handle(self, *args, **options):
db_conf = settings.DATABASES['default']
if db_conf['ENGINE'] != 'django.db.backends.sqlite3':
db_conf = settings.DATABASES["default"]
if db_conf["ENGINE"] != "django.db.backends.sqlite3":
self.stdout.write(self.style.ERROR("This command only supports SQLite."))
return
db_path = str(db_conf['NAME'])
backup_dir = os.path.join(settings.BASE_DIR, 'backups')
db_path = str(db_conf["NAME"])
backup_dir = os.path.join(settings.BASE_DIR, "backups")
os.makedirs(backup_dir, exist_ok=True)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
# 1. Database Backup
if options['output']:
db_backup_path = options['output']
if options["output"]:
db_backup_path = options["output"]
else:
db_backup_path = os.path.join(backup_dir, f'db_backup_{timestamp}.sqlite3')
db_backup_path = os.path.join(backup_dir, f"db_backup_{timestamp}.sqlite3")
self.stdout.write(f"Cloning database {db_path}...")
@@ -46,8 +51,9 @@ class Command(BaseCommand):
return
# 2. Full Archive (Optional)
if options['full']:
if options["full"]:
import tarfile
archive_name = f"full_backup_{timestamp}.tar.gz"
archive_path = os.path.join(backup_dir, archive_name)
self.stdout.write(f"Creating full archive at {archive_path}...")
@@ -62,15 +68,15 @@ class Command(BaseCommand):
tar.add(media_dir, arcname="media")
# Remove the raw sqlite backup after bundling it into the archive
if not options['output']:
if not options["output"]:
os.remove(db_backup_path)
self.stdout.write(self.style.SUCCESS(f"Full archive created successfully."))
self.stdout.write(self.style.SUCCESS("Full archive created successfully."))
except Exception as e:
self.stdout.write(self.style.ERROR(f"Archive creation failed: {e}"))
# 3. Cleanup Old Backups
retention_days = options['retention']
retention_days = options["retention"]
cutoff = datetime.now() - timedelta(days=retention_days)
self.stdout.write(f"Cleaning up backups older than {retention_days} days...")
@@ -81,6 +87,10 @@ class Command(BaseCommand):
if file_time < cutoff:
try:
os.remove(file_path)
self.stdout.write(self.style.SUCCESS(f" -> Deleted old backup: {filename}"))
self.stdout.write(
self.style.SUCCESS(f" -> Deleted old backup: {filename}")
)
except Exception as e:
self.stdout.write(self.style.WARNING(f" -> Failed to delete {filename}: {e}"))
self.stdout.write(
self.style.WARNING(f" -> Failed to delete {filename}: {e}")
)
@@ -1,33 +1,38 @@
from django.core.management.base import BaseCommand
from api.models import Flavor, Rating
from django.db.models import Count
from api.models import Flavor, Rating
class Command(BaseCommand):
help = 'Identifies and merges duplicate flavors (same name and category)'
help = "Identifies and merges duplicate flavors (same name and category)"
def handle(self, *args, **options):
# Find duplicates based on name and category
duplicates = Flavor.objects.values('name', 'category').annotate(
name_count=Count('id')
).filter(name_count__gt=1)
duplicates = (
Flavor.objects.values("name", "category")
.annotate(name_count=Count("id"))
.filter(name_count__gt=1)
)
self.stdout.write(f"Found {duplicates.count()} sets of duplicates.")
for dup in duplicates:
name = dup['name']
category_id = dup['category']
name = dup["name"]
category_id = dup["category"]
# Get all flavors in this set, ordered by having an external_id first, then by date
flavor_set = Flavor.objects.filter(
name=name,
category_id=category_id
).order_by('-external_id', 'created_at')
flavor_set = Flavor.objects.filter(name=name, category_id=category_id).order_by(
"-external_id", "created_at"
)
# Keep the first one, merge others into it
keep = flavor_set[0]
others = flavor_set[1:]
self.stdout.write(f"Merging {len(others)} duplicates into '{keep.name}' (ID: {keep.id}, Ext: {keep.external_id})")
self.stdout.write(
f"Merging {len(others)} duplicates into '{keep.name}' (ID: {keep.id}, Ext: {keep.external_id})"
)
for other in others:
# Re-assign ratings
@@ -0,0 +1,94 @@
"""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")
+28 -17
View File
@@ -1,45 +1,48 @@
from django.core.management.base import BaseCommand
from api.models import Banner
import os
import json
import os
from django.core.management.base import BaseCommand
from api.models import Banner
class Command(BaseCommand):
help = 'Seeds banner models and missing settings from JSON files in backend/banners/'
help = "Seeds banner models and missing settings from JSON files in backend/banners/"
def handle(self, *args, **options):
# Determine the banners directory relative to the project root
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
banners_dir = os.path.join(project_root, 'banners')
banners_dir = os.path.join(project_root, "banners")
if not os.path.exists(banners_dir):
self.stdout.write(self.style.WARNING(f"Banners directory not found at {banners_dir}"))
return
for filename in os.listdir(banners_dir):
if filename.endswith('.json'):
if filename.endswith(".json"):
filepath = os.path.join(banners_dir, filename)
try:
with open(filepath, 'r') as f:
with open(filepath) as f:
data = json.load(f)
slug = data.get('slug')
slug = data.get("slug")
if not slug:
continue
banner, created = Banner.objects.get_or_create(
slug=slug,
defaults={
'name': data.get('name', slug),
'description': data.get('description', ''),
'settings': data.get('settings', {})
}
"name": data.get("name", slug),
"description": data.get("description", ""),
"settings": data.get("settings", {}),
},
)
if not created:
# Sync missing settings from JSON to DB without overwriting existing keys
db_settings = banner.settings or {}
json_settings = data.get('settings', {})
json_schema = data.get('schema', [])
json_settings = data.get("settings", {})
json_schema = data.get("schema", [])
updated = False
# Sync missing settings
@@ -56,9 +59,17 @@ class Command(BaseCommand):
if updated:
banner.settings = db_settings
banner.save()
self.stdout.write(self.style.SUCCESS(f"Synced new settings for banner '{slug}' from {filename}"))
self.stdout.write(
self.style.SUCCESS(
f"Synced new settings for banner '{slug}' from {filename}"
)
)
else:
self.stdout.write(self.style.SUCCESS(f"Created new banner '{slug}' from {filename}"))
self.stdout.write(
self.style.SUCCESS(f"Created new banner '{slug}' from {filename}")
)
except Exception as e:
self.stdout.write(self.style.ERROR(f"Failed to sync banner from {filename}: {e}"))
self.stdout.write(
self.style.ERROR(f"Failed to sync banner from {filename}: {e}")
)
@@ -1,41 +1,44 @@
import hashlib
import json
import os
from urllib.parse import urlparse
import requests
from django.core.management.base import BaseCommand
from django.core.files import File
from django.conf import settings
from django.core.management.base import BaseCommand
from django.utils.text import slugify
from api.models import Category, Flavor
class Command(BaseCommand):
help = 'Adds legacy flavors from JSON files in the legacy/ folder'
help = "Adds legacy flavors from JSON files in the legacy/ folder"
def download_image(self, url, flavor_name):
"""Download legacy image into flavors/legacy_<slug>/00_<hash>.<ext>. Returns rel path."""
if not url:
return None
try:
safe_name = slugify(flavor_name)
ext = url.split('.')[-1].split('?')[0].lower()
if ext not in ['jpg', 'jpeg', 'png', 'webp', 'gif']:
ext = 'png'
filename = f"legacy_{safe_name}.{ext}"
filepath = os.path.join(settings.MEDIA_ROOT, 'flavors', filename)
os.makedirs(os.path.dirname(filepath), exist_ok=True)
response = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=10)
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()
if os.path.exists(filepath):
os.remove(filepath)
with open(filepath, 'wb') as f:
with open(abs_path, "wb") as f:
f.write(response.content)
return f"flavors/{filename}"
return rel_path
except Exception as e:
self.stdout.write(self.style.WARNING(f"Failed to download image for {flavor_name}: {e}"))
self.stdout.write(
self.style.WARNING(f"Failed to download image for {flavor_name}: {e}")
)
return None
def handle(self, *args, **options):
@@ -43,22 +46,30 @@ class Command(BaseCommand):
# Instead, we update existing entries.
cat_map = {
'energy': Category.objects.get_or_create(name='Energy', defaults={'slug': 'energy'})[0],
'hydration': Category.objects.get_or_create(name='Hydration', defaults={'slug': 'hydration'})[0],
'iced-tea': Category.objects.get_or_create(name='Iced Tea', defaults={'slug': 'iced-tea'})[0],
'milkshake': Category.objects.get_or_create(name='Milkshake', defaults={'slug': 'milkshake'})[0],
'packs': Category.objects.get_or_create(name='Packs and other', defaults={'slug': 'packs-and-other'})[0],
"energy": Category.objects.get_or_create(name="Energy", defaults={"slug": "energy"})[0],
"hydration": Category.objects.get_or_create(
name="Hydration", defaults={"slug": "hydration"}
)[0],
"iced-tea": Category.objects.get_or_create(
name="Iced Tea", defaults={"slug": "iced-tea"}
)[0],
"milkshake": Category.objects.get_or_create(
name="Milkshake", defaults={"slug": "milkshake"}
)[0],
"packs": Category.objects.get_or_create(
name="Packs and other", defaults={"slug": "packs-and-other"}
)[0],
}
legacy_dir = os.path.join(settings.BASE_DIR, 'legacy')
legacy_dir = os.path.join(settings.BASE_DIR, "legacy")
if not os.path.exists(legacy_dir):
legacy_dir = os.path.join(settings.BASE_DIR, '..', 'legacy')
legacy_dir = os.path.join(settings.BASE_DIR, "..", "legacy")
if not os.path.exists(legacy_dir):
self.stdout.write(self.style.ERROR(f"Legacy directory not found."))
self.stdout.write(self.style.ERROR("Legacy directory not found."))
return
json_files = [f for f in os.listdir(legacy_dir) if f.endswith('.json')]
json_files = [f for f in os.listdir(legacy_dir) if f.endswith(".json")]
processed_count = 0
for json_file in json_files:
@@ -66,26 +77,26 @@ class Command(BaseCommand):
file_lower = json_file.lower()
# Determine category from filename
if 'energy' in file_lower:
file_cat = cat_map['energy']
elif 'iced_tea' in file_lower or 'iced-tea' in file_lower:
file_cat = cat_map['iced-tea']
elif 'hydration' in file_lower:
file_cat = cat_map['hydration']
elif 'milkshake' in file_lower:
file_cat = cat_map['milkshake']
if "energy" in file_lower:
file_cat = cat_map["energy"]
elif "iced_tea" in file_lower or "iced-tea" in file_lower:
file_cat = cat_map["iced-tea"]
elif "hydration" in file_lower:
file_cat = cat_map["hydration"]
elif "milkshake" in file_lower:
file_cat = cat_map["milkshake"]
else:
file_cat = None
with open(file_path, 'r', encoding='utf-8') as f:
with open(file_path, encoding="utf-8") as f:
data_list = json.load(f)
for data in data_list:
name = data.get('Name')
desc = data.get('Beschreibung')
taste = data.get('Geschmack')
status_text = data.get('Status')
image_url = data.get('Bild_URL')
name = data.get("Name")
desc = data.get("Beschreibung")
taste = data.get("Geschmack")
status_text = data.get("Status")
image_url = data.get("Bild_URL")
full_desc = f"**Geschmack:** {taste}\n\n{desc}\n\n**Status:** {status_text}"
@@ -94,15 +105,27 @@ class Command(BaseCommand):
if not cat:
# Fallback to name/desc detection if filename was ambiguous
if "hydration" in name.lower() or "hydration" in desc.lower():
cat = cat_map['hydration']
cat = cat_map["hydration"]
elif "iced tea" in name.lower() or "eistee" in name.lower():
cat = cat_map['iced-tea']
cat = cat_map["iced-tea"]
elif "milkshake" in name.lower():
cat = cat_map['milkshake']
elif any(k in name.lower() for k in ['bundle', 'set', 'box', 'probe', 'sample', 'taster', 'shaker', 'starter']):
cat = cat_map['packs']
cat = cat_map["milkshake"]
elif any(
k in name.lower()
for k in [
"bundle",
"set",
"box",
"probe",
"sample",
"taster",
"shaker",
"starter",
]
):
cat = cat_map["packs"]
else:
cat = cat_map['energy'] # Ultimate fallback
cat = cat_map["energy"] # Ultimate fallback
# Try to find existing flavor by name and category regardless of legacy status
flavor = Flavor.objects.filter(name__iexact=name, category=cat).first()
@@ -113,12 +136,7 @@ class Command(BaseCommand):
continue
if not flavor:
flavor = Flavor(
name=name,
category=cat,
is_legacy=True,
external_id=None
)
flavor = Flavor(name=name, category=cat, is_legacy=True, external_id=None)
self.stdout.write(f"Adding new legacy flavor: {name}")
else:
self.stdout.write(f"Updating legacy flavor: {name}")
@@ -127,22 +145,21 @@ class Command(BaseCommand):
flavor.is_available = False # Legacy is always unavailable via API
if image_url:
# Check if local file exists
file_exists = False
if flavor.image:
abs_path = os.path.join(settings.MEDIA_ROOT, flavor.image.name)
if os.path.exists(abs_path):
file_exists = True
# Download if missing OR if image_url changed
if not file_exists or flavor.image_url != image_url:
rel_path = self.download_image(image_url, name)
if rel_path:
flavor.image = rel_path
self.stdout.write(f" -> Updated image for: {name}")
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."))
self.stdout.write(
self.style.SUCCESS(f"Finished! Processed {processed_count} legacy flavors.")
)
+143 -87
View File
@@ -1,72 +1,114 @@
import json
import requests
import re
import hashlib
import os
from django.core.management.base import BaseCommand
from django.core.files import File
import re
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import urlparse
import requests
from django.conf import settings
from django.utils.text import slugify
from django.core.management.base import BaseCommand
from api.models import Category, Flavor
_DOWNLOAD_WORKERS = 8
class Command(BaseCommand):
help = 'Syncs flavors from Holy Energy Shopify API'
help = "Syncs flavors from Holy Energy Shopify API"
def clean_html(self, raw_html):
if not raw_html:
return ""
cleanr = re.compile('<.*?>')
cleantext = re.sub(cleanr, '', raw_html)
cleanr = re.compile("<.*?>")
cleantext = re.sub(cleanr, "", raw_html)
return cleantext.strip()
def download_image(self, url, flavor_name):
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:
safe_name = slugify(flavor_name)
ext = url.split('.')[-1].split('?')[0].lower()
if ext not in ['jpg', 'jpeg', 'png', 'webp', 'gif']:
ext = 'png'
filename = f"{safe_name}.{ext}"
filepath = os.path.join(settings.MEDIA_ROOT, 'flavors', filename)
os.makedirs(os.path.dirname(filepath), exist_ok=True)
response = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=10)
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()
if os.path.exists(filepath):
os.remove(filepath)
with open(filepath, 'wb') as f:
with open(abs_path, "wb") as f:
f.write(response.content)
return f"flavors/{filename}"
return rel_path
except Exception as e:
self.stdout.write(self.style.WARNING(f"Failed to download image for {flavor_name}: {e}"))
self.stdout.write(self.style.WARNING(f"Failed to download {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]
def handle(self, *args, **options):
url = "https://weareholy.com/products.json?limit=250"
self.stdout.write(f"Fetching from {url}...")
try:
response = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=15)
response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=15)
response.raise_for_status()
data = response.json()
except Exception as e:
self.stdout.write(self.style.ERROR(f"Failed to fetch data: {e}"))
return
products = data.get('products', [])
products = data.get("products", [])
self.stdout.write(f"Found {len(products)} products.")
cat_map = {
'energy': Category.objects.get_or_create(name='Energy', defaults={'slug': 'energy'})[0],
'hydration': Category.objects.get_or_create(name='Hydration', defaults={'slug': 'hydration'})[0],
'iced-tea': Category.objects.get_or_create(name='Iced Tea', defaults={'slug': 'iced-tea'})[0],
'milkshake': Category.objects.get_or_create(name='Milkshake', defaults={'slug': 'milkshake'})[0],
'packs': Category.objects.get_or_create(name='Packs and other', defaults={'slug': 'packs-and-other'})[0],
"energy": Category.objects.get_or_create(name="Energy", defaults={"slug": "energy"})[0],
"hydration": Category.objects.get_or_create(
name="Hydration", defaults={"slug": "hydration"}
)[0],
"iced-tea": Category.objects.get_or_create(
name="Iced Tea", defaults={"slug": "iced-tea"}
)[0],
"milkshake": Category.objects.get_or_create(
name="Milkshake", defaults={"slug": "milkshake"}
)[0],
"packs": Category.objects.get_or_create(
name="Packs and other", defaults={"slug": "packs-and-other"}
)[0],
}
created_count = 0
@@ -74,102 +116,116 @@ class Command(BaseCommand):
synced_external_ids = []
for p in products:
title = p.get('title', '').strip()
tags = p.get('tags', [])
title = p.get("title", "").strip()
tags = p.get("tags", [])
if isinstance(tags, str):
tags = [t.strip() for t in tags.split(',')]
tags = [t.strip() for t in tags.split(",")]
tags_lower = [t.lower() for t in tags]
title_lower = title.lower()
is_pack = False
pack_keywords = ['bundle', 'set', 'box', 'probe', 'sample', 'taster', 'starter', 'collection', 'probier']
pack_keywords = [
"bundle",
"set",
"box",
"probe",
"sample",
"taster",
"starter",
"collection",
"probier",
]
if any(k in title_lower for k in pack_keywords):
is_pack = True
if 'shaker' in title_lower or 'merch' in tags_lower:
if "shaker" in title_lower or "merch" in tags_lower:
is_pack = True
category = None
if is_pack:
category = cat_map['packs']
elif 'holy energy' in tags_lower or 'energy' in tags_lower:
category = cat_map['energy']
elif 'holy hydration' in tags_lower or 'hydration' in tags_lower:
category = cat_map['hydration']
elif 'holy iced tea' in tags_lower or 'iced tea' in tags_lower:
category = cat_map['iced-tea']
elif 'milkshake' in tags_lower:
category = cat_map['milkshake']
category = cat_map["packs"]
elif "holy energy" in tags_lower or "energy" in tags_lower:
category = cat_map["energy"]
elif "holy hydration" in tags_lower or "hydration" in tags_lower:
category = cat_map["hydration"]
elif "holy iced tea" in tags_lower or "iced tea" in tags_lower:
category = cat_map["iced-tea"]
elif "milkshake" in tags_lower:
category = cat_map["milkshake"]
if not category:
if 'shaker' in tags_lower:
category = cat_map['packs']
if "shaker" in tags_lower:
category = cat_map["packs"]
else:
continue
variants = p.get('variants', [])
is_available = any(v.get('available') for v in variants)
images = p.get('images', [])
image_url = images[0].get('src') if images else None
description = self.clean_html(p.get('body_html', ''))
handle = p.get('handle')
variants = p.get("variants", [])
is_available = any(v.get("available") for v in variants)
images = p.get("images", [])
image_url = images[0].get("src") if images else None
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
# Find or create - be very specific to avoid duplicates
# 1. Try external_id
flavor = Flavor.objects.filter(external_id=p['id']).first()
flavor = Flavor.objects.filter(external_id=p["id"]).first()
if not flavor:
# 2. Try exact name match in category
# REMOVED: is_legacy=False
flavor = Flavor.objects.filter(name=title, category=category).first()
if not flavor:
# 3. Try case-insensitive name match in category (safety for whitespace/case)
# REMOVED: is_legacy=False
flavor = Flavor.objects.filter(name__iexact=title, category=category).first()
if not flavor:
flavor = Flavor(external_id=p['id'], name=title, category=category, is_legacy=False)
flavor = Flavor(external_id=p["id"], name=title, category=category, is_legacy=False)
created_count += 1
else:
updated_count += 1
# Ensure external_id is updated if it was missing
if not flavor.external_id:
flavor.external_id = p['id']
# Reactivate the flavor if it was previously marked as legacy
flavor.external_id = p["id"]
if flavor.is_legacy:
flavor.is_legacy = False
# Ensure all synced fields are accurately reflected
if flavor.name != title and not Flavor.objects.filter(name=title, category=category).exclude(pk=flavor.pk).exists():
if (
flavor.name != title
and not Flavor.objects.filter(name=title, category=category)
.exclude(pk=flavor.pk)
.exists()
):
flavor.name = title
flavor.description = description
flavor.shop_url = shop_url
flavor.is_available = is_available
if image_url:
# Check if local file exists
file_exists = False
if flavor.image:
abs_path = os.path.join(settings.MEDIA_ROOT, flavor.image.name)
if os.path.exists(abs_path):
file_exists = True
# Download if missing or URL changed
if not file_exists or flavor.image_url != image_url:
rel_path = self.download_image(image_url, title)
if rel_path:
flavor.image = rel_path
self.stdout.write(f"Downloaded image for: {title}")
else:
self.stdout.write(self.style.WARNING(f"Failed to ensure image for: {title}"))
# Download whole gallery into flavors/<external_id>/
dir_slug = str(p["id"])
local_paths = self.download_gallery(image_urls_list, 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_list
flavor.save()
synced_external_ids.append(p['id'])
synced_external_ids.append(p["id"])
discontinued_count = Flavor.objects.filter(external_id__isnull=False).exclude(external_id__in=synced_external_ids).update(is_available=False)
self.stdout.write(self.style.SUCCESS(f"Finished! Created: {created_count}, Updated: {updated_count}, Marked Unavailable: {discontinued_count}"))
discontinued_count = (
Flavor.objects.filter(external_id__isnull=False)
.exclude(external_id__in=synced_external_ids)
.update(is_available=False)
)
self.stdout.write(
self.style.SUCCESS(
f"Finished! Created: {created_count}, Updated: {updated_count}, Marked Unavailable: {discontinued_count}"
)
)
-81
View File
@@ -1,81 +0,0 @@
import time
import sys
from datetime import datetime, timedelta
from django.core.management.base import BaseCommand
from django.core.management import call_command
from django.utils import timezone
from django.db import models
from api.models import Job
from io import StringIO
class Command(BaseCommand):
help = 'Background worker to process scheduled and on-demand jobs.'
def handle(self, *args, **options):
self.stdout.write(self.style.SUCCESS("Starting Holy Archive Worker..."))
# Ensure all core jobs exist in the DB
for job_type, label in Job.JOB_TYPES:
Job.objects.get_or_create(name=job_type)
while True:
# 1. Find jobs that are either explicitly set to 'pending' (manual trigger)
# or those where next_run has passed.
now = timezone.now()
jobs_to_run = Job.objects.filter(
~models.Q(status='running')
).filter(
# Run if pending OR (next_run is set AND has passed)
models.Q(status='pending') |
models.Q(next_run__lte=now, interval_hours__gt=0)
)
for job in jobs_to_run:
self.run_job(job)
time.sleep(30) # Check every 30 seconds
def run_job(self, job):
self.stdout.write(f"Executing Job: {job.get_name_display()}...")
job.status = 'running'
job.last_run = timezone.now()
job.last_output = "" # Reset output for new run
job.save()
# Capture management command output
class LiveStream(StringIO):
def write(self, s):
super().write(s)
# Periodically update the DB with current output
job.last_output = self.getvalue()
job.save(update_fields=['last_output'])
out = LiveStream()
error = ""
try:
if job.name == 'sync_flavors':
call_command('sync_flavors', stdout=out)
elif job.name == 'cleanup_duplicates':
call_command('cleanup_duplicates', stdout=out)
elif job.name == 'backup_db':
call_command('backup_db', '--full', stdout=out)
elif job.name == 'seed_legacy':
call_command('seed_legacy_flavors', stdout=out)
job.status = 'completed'
except Exception as e:
job.status = 'failed'
error = str(e)
self.stdout.write(self.style.ERROR(f"Job failed: {error}"))
# Update scheduling for next run if interval is set
if job.interval_hours > 0:
job.next_run = timezone.now() + timedelta(hours=job.interval_hours)
else:
job.next_run = None
job.last_output = out.getvalue()
job.error_message = error
job.save()
self.stdout.write(self.style.SUCCESS(f"Job finished: {job.status}"))
@@ -0,0 +1,18 @@
# Generated by Django 6.0.4 on 2026-04-11 00:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0029_alter_job_name'),
]
operations = [
migrations.AddField(
model_name='flavor',
name='image_urls',
field=models.JSONField(blank=True, default=list),
),
]
@@ -0,0 +1,18 @@
# 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),
),
]
@@ -0,0 +1,23 @@
# 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),
),
]
@@ -0,0 +1,18 @@
# 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),
),
]
@@ -0,0 +1,18 @@
# 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),
),
]
-266
View File
@@ -1,266 +0,0 @@
from django.db import models
from django.contrib.auth.models import AbstractUser
from django.core.validators import MinValueValidator, MaxValueValidator
class User(AbstractUser):
THEME_CHOICES = [
('holy_light', 'Holy Light'),
('holy_dark', 'Holy Dark'),
('latte', 'Catppuccin Latte'),
('frappe', 'Catppuccin Frappé'),
('macchiato', 'Catppuccin Macchiato'),
('mocha', 'Catppuccin Mocha'),
('pink_pastel', 'Pink Pastel'),
('mint_pastel', 'Mint Pastel'),
('lavender_pastel', 'Lavender Pastel'),
('dracula', 'Dracula'),
('nord', 'Nordic Frost'),
('gruvbox', 'Gruvbox Retro'),
('oceanic', 'Oceanic Deep'),
('t0p_sai', 'SAI (T0P)'),
('t0p_trench', 'Trench (T0P)'),
('t0p_blurryface', 'Blurryface (T0P)'),
('t0p_clancy', 'Clancy (T0P)'),
]
LANGUAGE_CHOICES = [
('en', 'English'),
('de', 'Deutsch'),
]
theme = models.CharField(max_length=20, choices=THEME_CHOICES, default='holy_light')
language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default='en')
avatar = models.ImageField(upload_to='avatars/', blank=True, null=True)
pending_email = models.EmailField(max_length=254, blank=True, null=True)
email_confirmation_code = models.CharField(max_length=6, blank=True, null=True)
following = models.ManyToManyField('self', symmetrical=False, related_name='followers', blank=True)
selected_banner = models.ForeignKey('Banner', on_delete=models.SET_NULL, null=True, blank=True, related_name='users')
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.avatar:
try:
from PIL import Image
img = Image.open(self.avatar.path)
if img.height > 256 or img.width > 256 or img.format != 'JPEG':
output_size = (256, 256)
img.thumbnail(output_size)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(self.avatar.path, 'JPEG', quality=90)
except Exception:
pass
def __str__(self):
return self.username
class Category(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
class Meta:
verbose_name_plural = "Categories"
ordering = ['name']
def __str__(self):
return self.name
class Flavor(models.Model):
category = models.ForeignKey(Category, related_name='flavors', on_delete=models.CASCADE)
name = models.CharField(max_length=100) # Removed unique=True here
description = models.TextField(blank=True)
image_url = models.URLField(max_length=1000, blank=True, null=True)
image = models.ImageField(upload_to='flavors/', blank=True, null=True)
shop_url = models.URLField(max_length=500, blank=True, null=True)
is_available = models.BooleanField(default=True)
is_legacy = models.BooleanField(default=False)
external_id = models.BigIntegerField(unique=True, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
# This allows "Erdbeere" in Iced Tea and "Erdbeere" in Milkshake
constraints = [
models.UniqueConstraint(fields=['name', 'category'], name='unique_flavor_name_per_category')
]
ordering = ['name']
def __str__(self):
return f"{self.name} ({self.category.name})"
@property
def get_average_rating(self):
ratings = self.ratings.all()
if ratings.exists():
return sum(r.score for r in ratings) / ratings.count()
return 0.0
@property
def cached_image_url(self):
if self.image:
return self.image.url
return self.image_url
class Rating(models.Model):
user = models.ForeignKey(User, related_name='ratings', on_delete=models.CASCADE)
flavor = models.ForeignKey(Flavor, related_name='ratings', on_delete=models.CASCADE)
score = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(10)])
comment = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('user', 'flavor')
ordering = ['-created_at']
def __str__(self):
return f"{self.user.username} - {self.flavor.name}: {self.score}"
class Reply(models.Model):
user = models.ForeignKey(User, related_name='replies', on_delete=models.CASCADE)
rating = models.ForeignKey(Rating, related_name='replies', on_delete=models.CASCADE)
text = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['created_at']
def __str__(self):
return f"Reply by {self.user.username} on {self.rating.id}"
class Notification(models.Model):
TYPE_CHOICES = [
('reply', 'Reply'),
('mention', 'Mention'),
('follow', 'Follow'),
('ticket_new', 'New Ticket'),
('ticket_reply', 'Ticket Reply'),
('profile_comment', 'Profile Comment'),
]
recipient = models.ForeignKey(User, related_name='notifications', on_delete=models.CASCADE)
actor = models.ForeignKey(User, related_name='actions', on_delete=models.CASCADE)
notification_type = models.CharField(max_length=20, choices=TYPE_CHOICES)
rating = models.ForeignKey(Rating, on_delete=models.CASCADE, null=True, blank=True)
reply = models.ForeignKey(Reply, on_delete=models.CASCADE, null=True, blank=True)
ticket = models.ForeignKey('Ticket', on_delete=models.CASCADE, null=True, blank=True)
profile_comment = models.ForeignKey('ProfileComment', on_delete=models.CASCADE, null=True, blank=True)
is_read = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"{self.notification_type} for {self.recipient.username}"
class UserIP(models.Model):
user = models.ForeignKey(User, related_name='ips', on_delete=models.CASCADE)
ip_address = models.GenericIPAddressField()
last_login = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('user', 'ip_address')
ordering = ['-last_login']
class Ticket(models.Model):
STATUS_CHOICES = [
('open', 'Open'),
('in_progress', 'In Progress'),
('resolved', 'Resolved'),
('closed', 'Closed'),
]
user = models.ForeignKey(User, related_name='tickets', on_delete=models.CASCADE)
subject = models.CharField(max_length=200)
description = models.TextField()
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='open')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-updated_at']
class TicketMessage(models.Model):
ticket = models.ForeignKey(Ticket, related_name='messages', on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE)
text = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['created_at']
class ProfileComment(models.Model):
profile_owner = models.ForeignKey(User, related_name='profile_comments', on_delete=models.CASCADE)
author = models.ForeignKey(User, related_name='authored_profile_comments', on_delete=models.CASCADE)
text = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
class Banner(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
description = models.TextField(blank=True)
is_active = models.BooleanField(default=False, help_text="Designates this as the global default banner.")
is_enabled = models.BooleanField(default=True, help_text="Allows users to select this banner.")
settings = models.JSONField(default=dict, blank=True)
schema = models.JSONField(default=list, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
if self.is_active:
# Set all others to inactive
Banner.objects.filter(is_active=True).exclude(pk=self.pk).update(is_active=False)
elif not Banner.objects.filter(is_active=True).exclude(pk=self.pk).exists():
# If this was the only active banner, don't allow deactivating it
# unless there's another one to take its place.
self.is_active = True
super().save(*args, **kwargs)
def __str__(self):
return self.name
class SystemConfig(models.Model):
"""Singleton model for application-wide settings editable via UI."""
site_name = models.CharField(max_length=100, default="Holy Flavors Archive")
maintenance_mode = models.BooleanField(default=False)
allow_new_signups = models.BooleanField(default=True)
require_email_verification = models.BooleanField(default=True)
updated_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
self.pk = 1 # Force singleton
super().save(*args, **kwargs)
@classmethod
def get_solo(cls):
obj, _ = cls.objects.get_or_create(pk=1)
return obj
class Meta:
verbose_name = "System Configuration"
class Job(models.Model):
"""Tracks background jobs, their status and scheduling."""
JOB_TYPES = [
('sync_flavors', 'Sync Holy Flavors'),
('cleanup_duplicates', 'Cleanup Duplicate Flavors'),
('backup_db', 'Database Backup'),
('seed_legacy', 'Seed Legacy Data'),
]
STATUS_CHOICES = [
('pending', 'Pending'),
('running', 'Running'),
('completed', 'Completed'),
('failed', 'Failed'),
]
name = models.CharField(max_length=100, choices=JOB_TYPES)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
last_run = models.DateTimeField(null=True, blank=True)
next_run = models.DateTimeField(null=True, blank=True)
interval_hours = models.IntegerField(default=0, help_text="Set to 0 to disable periodic run.")
last_output = models.TextField(blank=True)
error_message = models.TextField(blank=True)
def __str__(self):
return f"{self.get_name_display()} ({self.status})"
+22
View File
@@ -0,0 +1,22 @@
from api.models.flavor import Category, Flavor
from api.models.rating import Rating, Reply
from api.models.social import Notification, ProfileComment
from api.models.support import Ticket, TicketMessage
from api.models.system import Banner, Job, SystemConfig
from api.models.user import User, UserIP
__all__ = [
"Banner",
"Category",
"Flavor",
"Job",
"Notification",
"ProfileComment",
"Rating",
"Reply",
"SystemConfig",
"Ticket",
"TicketMessage",
"User",
"UserIP",
]
+53
View File
@@ -0,0 +1,53 @@
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
@@ -0,0 +1,30 @@
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
class Rating(models.Model):
user = models.ForeignKey("api.User", related_name="ratings", on_delete=models.CASCADE)
flavor = models.ForeignKey("api.Flavor", related_name="ratings", on_delete=models.CASCADE)
score = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(10)])
comment = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ("user", "flavor")
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.user.username} - {self.flavor.name}: {self.score}"
class Reply(models.Model):
user = models.ForeignKey("api.User", related_name="replies", on_delete=models.CASCADE)
rating = models.ForeignKey(Rating, related_name="replies", on_delete=models.CASCADE)
text = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["created_at"]
def __str__(self) -> str:
return f"Reply by {self.user.username} on {self.rating.id}"
+45
View File
@@ -0,0 +1,45 @@
from django.db import models
class Notification(models.Model):
TYPE_CHOICES = [
("reply", "Reply"),
("mention", "Mention"),
("follow", "Follow"),
("ticket_new", "New Ticket"),
("ticket_reply", "Ticket Reply"),
("profile_comment", "Profile Comment"),
]
recipient = models.ForeignKey(
"api.User", related_name="notifications", on_delete=models.CASCADE
)
actor = models.ForeignKey("api.User", related_name="actions", on_delete=models.CASCADE)
notification_type = models.CharField(max_length=20, choices=TYPE_CHOICES)
rating = models.ForeignKey("api.Rating", on_delete=models.CASCADE, null=True, blank=True)
reply = models.ForeignKey("api.Reply", on_delete=models.CASCADE, null=True, blank=True)
ticket = models.ForeignKey("api.Ticket", on_delete=models.CASCADE, null=True, blank=True)
profile_comment = models.ForeignKey(
"api.ProfileComment", on_delete=models.CASCADE, null=True, blank=True
)
is_read = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.notification_type} for {self.recipient.username}"
class ProfileComment(models.Model):
profile_owner = models.ForeignKey(
"api.User", related_name="profile_comments", on_delete=models.CASCADE
)
author = models.ForeignKey(
"api.User", related_name="authored_profile_comments", on_delete=models.CASCADE
)
text = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
+29
View File
@@ -0,0 +1,29 @@
from django.db import models
class Ticket(models.Model):
STATUS_CHOICES = [
("open", "Open"),
("in_progress", "In Progress"),
("resolved", "Resolved"),
("closed", "Closed"),
]
user = models.ForeignKey("api.User", related_name="tickets", on_delete=models.CASCADE)
subject = models.CharField(max_length=200)
description = models.TextField()
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="open")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-updated_at"]
class TicketMessage(models.Model):
ticket = models.ForeignKey(Ticket, related_name="messages", on_delete=models.CASCADE)
user = models.ForeignKey("api.User", on_delete=models.CASCADE)
text = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["created_at"]
+78
View File
@@ -0,0 +1,78 @@
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
@@ -0,0 +1,77 @@
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"]
-237
View File
@@ -1,237 +0,0 @@
from rest_framework import serializers
from .models import User, Flavor, Category, Rating, Reply, Notification, Ticket, TicketMessage, UserIP, Banner, SystemConfig, Job
class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = Category
fields = ['id', 'name', 'slug']
class SystemConfigSerializer(serializers.ModelSerializer):
class Meta:
model = SystemConfig
fields = ['site_name', 'maintenance_mode', 'allow_new_signups', 'require_email_verification', 'updated_at']
class JobSerializer(serializers.ModelSerializer):
name_display = serializers.CharField(source='get_name_display', read_only=True)
class Meta:
model = Job
fields = ['id', 'name', 'name_display', 'status', 'last_run', 'next_run', 'interval_hours', 'last_output', 'error_message']
read_only_fields = ['status', 'last_run', 'next_run', 'last_output', 'error_message']
class BannerSerializer(serializers.ModelSerializer):
class Meta:
model = Banner
fields = ['id', 'name', 'slug', 'description', 'is_active', 'is_enabled', 'settings', 'schema', 'created_at', 'updated_at']
class ReplySerializer(serializers.ModelSerializer):
user = serializers.StringRelatedField(read_only=True)
class Meta:
model = Reply
fields = ['id', 'user', 'rating', 'text', 'created_at']
read_only_fields = ['rating']
class RatingSerializer(serializers.ModelSerializer):
user = serializers.StringRelatedField(read_only=True)
user_id = serializers.IntegerField(source='user.id', read_only=True)
user_avatar = serializers.SerializerMethodField()
flavor_name = serializers.CharField(source='flavor.name', read_only=True)
flavor_image = serializers.SerializerMethodField()
category_name = serializers.CharField(source='flavor.category.name', read_only=True)
category_slug = serializers.CharField(source='flavor.category.slug', read_only=True)
is_available = serializers.BooleanField(source='flavor.is_available', read_only=True)
is_legacy = serializers.BooleanField(source='flavor.is_legacy', read_only=True)
replies = ReplySerializer(many=True, read_only=True)
class Meta:
model = Rating
fields = ['id', 'user', 'user_id', 'user_avatar', 'flavor', 'flavor_name', 'flavor_image', 'category_name', 'category_slug', 'is_available', 'is_legacy', 'score', 'comment', 'created_at', 'replies']
def get_user_avatar(self, obj):
if obj.user.avatar:
request = self.context.get('request')
if request:
return request.build_absolute_uri(obj.user.avatar.url)
return obj.user.avatar.url
return None
def get_flavor_image(self, obj):
if obj.flavor.image:
request = self.context.get('request')
if request:
return request.build_absolute_uri(obj.flavor.image.url)
return obj.flavor.image.url
return obj.flavor.image_url
class FlavorSerializer(serializers.ModelSerializer):
category_name = serializers.CharField(source='category.name', read_only=True)
category_slug = serializers.CharField(source='category.slug', read_only=True)
average_rating = serializers.FloatField(read_only=True)
followed_average_rating = serializers.FloatField(read_only=True)
user_rating = serializers.SerializerMethodField()
ratings = RatingSerializer(many=True, read_only=True)
image_url = serializers.SerializerMethodField()
class Meta:
model = Flavor
fields = ['id', 'name', 'category', 'category_name', 'category_slug', 'description', 'average_rating', 'followed_average_rating', 'user_rating', 'ratings', 'image_url', 'image', 'is_available', 'is_legacy', 'shop_url']
extra_kwargs = {
'image': {'write_only': True}
}
def get_image_url(self, obj):
if obj.image:
request = self.context.get('request')
if request:
return request.build_absolute_uri(obj.image.url)
return obj.image.url
return obj.image_url
def get_user_rating(self, obj):
request = self.context.get('request')
if request and request.user.is_authenticated:
try:
rating = request.user.ratings.get(flavor=obj)
return rating.score
except Rating.DoesNotExist:
return None
return None
class UserSerializer(serializers.ModelSerializer):
avatar = serializers.SerializerMethodField()
following_count = serializers.IntegerField(source='following.count', read_only=True)
followers_count = serializers.IntegerField(source='followers.count', read_only=True)
is_following = serializers.SerializerMethodField()
unread_notifications_count = serializers.SerializerMethodField()
is_superuser = serializers.BooleanField(read_only=True)
selected_banner_slug = serializers.CharField(source='selected_banner.slug', read_only=True, allow_null=True)
class Meta:
model = User
fields = ['id', 'username', 'email', 'theme', 'language', 'avatar', 'following_count', 'followers_count', 'is_following', 'unread_notifications_count', 'is_superuser', 'selected_banner', 'selected_banner_slug']
def get_avatar(self, obj):
if obj.avatar:
request = self.context.get('request')
if request:
return request.build_absolute_uri(obj.avatar.url)
return obj.avatar.url
return None
def get_is_following(self, obj):
request = self.context.get('request')
if request and request.user.is_authenticated:
return request.user.following.filter(pk=obj.pk).exists()
return False
def get_unread_notifications_count(self, obj):
return obj.notifications.filter(is_read=False).count()
class UserIPSerializer(serializers.ModelSerializer):
class Meta:
model = UserIP
fields = ['id', 'ip_address', 'last_login']
class TicketMessageSerializer(serializers.ModelSerializer):
username = serializers.CharField(source='user.username', read_only=True)
is_admin = serializers.BooleanField(source='user.is_superuser', read_only=True)
class Meta:
model = TicketMessage
fields = ['id', 'username', 'text', 'created_at', 'is_admin']
class TicketSerializer(serializers.ModelSerializer):
username = serializers.CharField(source='user.username', read_only=True)
user_email = serializers.EmailField(source='user.email', read_only=True)
user_avatar = serializers.SerializerMethodField()
messages = TicketMessageSerializer(many=True, read_only=True)
class Meta:
model = Ticket
fields = ['id', 'user', 'username', 'user_email', 'user_avatar', 'subject', 'description', 'status', 'created_at', 'updated_at', 'messages']
read_only_fields = ['status', 'user']
def get_user_avatar(self, obj):
if obj.user.avatar:
request = self.context.get('request')
if request:
return request.build_absolute_uri(obj.user.avatar.url)
return obj.user.avatar.url
return None
class ProfileCommentSerializer(serializers.ModelSerializer):
author_username = serializers.CharField(source='author.username', read_only=True)
author_avatar = serializers.SerializerMethodField()
class Meta:
from .models import ProfileComment
model = ProfileComment
fields = ['id', 'author_username', 'author_avatar', 'text', 'created_at']
def get_author_avatar(self, obj):
if obj.author.avatar:
request = self.context.get('request')
if request:
return request.build_absolute_uri(obj.author.avatar.url)
return obj.author.avatar.url
return None
class AdminUserListSerializer(serializers.ModelSerializer):
ips = serializers.SerializerMethodField()
class Meta:
model = User
fields = ['id', 'username', 'email', 'is_active', 'is_superuser', 'date_joined', 'last_login', 'ips']
def get_ips(self, obj):
return [ip.ip_address for ip in obj.ips.all()[:3]]
class AdminUserDetailSerializer(serializers.ModelSerializer):
ips = serializers.SerializerMethodField()
ratings = RatingSerializer(many=True, read_only=True)
class Meta:
model = User
fields = ['id', 'username', 'email', 'is_active', 'is_superuser', 'date_joined', 'last_login', 'ips', 'ratings']
def get_ips(self, obj):
return [ip.ip_address for ip in obj.ips.all()]
class NotificationSerializer(serializers.ModelSerializer):
actor_username = serializers.CharField(source='actor.username', read_only=True)
actor_avatar = serializers.SerializerMethodField()
flavor_name = serializers.SerializerMethodField()
flavor_id = serializers.SerializerMethodField()
profile_owner_username = serializers.SerializerMethodField()
class Meta:
model = Notification
fields = ['id', 'actor_username', 'actor_avatar', 'notification_type', 'rating', 'reply', 'ticket', 'profile_comment', 'profile_owner_username', 'is_read', 'created_at', 'flavor_name', 'flavor_id']
def get_actor_avatar(self, obj):
if obj.actor.avatar:
request = self.context.get('request')
if request:
return request.build_absolute_uri(obj.actor.avatar.url)
return obj.actor.avatar.url
return None
def get_flavor_name(self, obj):
if obj.rating:
return obj.rating.flavor.name
if obj.reply:
return obj.reply.rating.flavor.name
return None
def get_flavor_id(self, obj):
if obj.rating:
return obj.rating.flavor.id
if obj.reply:
return obj.reply.rating.flavor.id
return None
def get_profile_owner_username(self, obj):
if obj.profile_comment:
return obj.profile_comment.profile_owner.username
return None
+33
View File
@@ -0,0 +1,33 @@
from api.serializers.flavor import CategorySerializer, FlavorSerializer
from api.serializers.rating import RatingSerializer, ReplySerializer
from api.serializers.social import NotificationSerializer, ProfileCommentSerializer
from api.serializers.support import TicketMessageSerializer, TicketSerializer
from api.serializers.system import (
BannerSerializer,
JobSerializer,
SystemConfigSerializer,
)
from api.serializers.user import (
AdminUserDetailSerializer,
AdminUserListSerializer,
UserIPSerializer,
UserSerializer,
)
__all__ = [
"AdminUserDetailSerializer",
"AdminUserListSerializer",
"BannerSerializer",
"CategorySerializer",
"FlavorSerializer",
"JobSerializer",
"NotificationSerializer",
"ProfileCommentSerializer",
"RatingSerializer",
"ReplySerializer",
"SystemConfigSerializer",
"TicketMessageSerializer",
"TicketSerializer",
"UserIPSerializer",
"UserSerializer",
]
+23
View File
@@ -0,0 +1,23 @@
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
@@ -0,0 +1,89 @@
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
@@ -0,0 +1,54 @@
from rest_framework import serializers
from api.models import Rating, Reply
from api.serializers._helpers import absolute_image_url
class ReplySerializer(serializers.ModelSerializer):
user = serializers.StringRelatedField(read_only=True)
class Meta:
model = Reply
fields = ["id", "user", "rating", "text", "created_at"]
read_only_fields = ["rating"]
class RatingSerializer(serializers.ModelSerializer):
user = serializers.StringRelatedField(read_only=True)
user_id = serializers.IntegerField(source="user.id", read_only=True)
user_avatar = serializers.SerializerMethodField()
flavor_name = serializers.CharField(source="flavor.name", read_only=True)
flavor_image = serializers.SerializerMethodField()
category_name = serializers.CharField(source="flavor.category.name", read_only=True)
category_slug = serializers.CharField(source="flavor.category.slug", read_only=True)
is_available = serializers.BooleanField(source="flavor.is_available", read_only=True)
is_legacy = serializers.BooleanField(source="flavor.is_legacy", read_only=True)
replies = ReplySerializer(many=True, read_only=True)
class Meta:
model = Rating
fields = [
"id",
"user",
"user_id",
"user_avatar",
"flavor",
"flavor_name",
"flavor_image",
"category_name",
"category_slug",
"is_available",
"is_legacy",
"score",
"comment",
"created_at",
"replies",
]
def get_user_avatar(self, obj: Rating) -> str | None:
return absolute_image_url(self, obj.user.avatar)
def get_flavor_image(self, obj: Rating) -> str | None:
if obj.flavor.image:
return absolute_image_url(self, obj.flavor.image)
return obj.flavor.image_url
+64
View File
@@ -0,0 +1,64 @@
from rest_framework import serializers
from api.models import Notification, ProfileComment
from api.serializers._helpers import absolute_image_url
class ProfileCommentSerializer(serializers.ModelSerializer):
author_username = serializers.CharField(source="author.username", read_only=True)
author_avatar = serializers.SerializerMethodField()
class Meta:
model = ProfileComment
fields = ["id", "author_username", "author_avatar", "text", "created_at"]
def get_author_avatar(self, obj: ProfileComment) -> str | None:
return absolute_image_url(self, obj.author.avatar)
class NotificationSerializer(serializers.ModelSerializer):
actor_username = serializers.CharField(source="actor.username", read_only=True)
actor_avatar = serializers.SerializerMethodField()
flavor_name = serializers.SerializerMethodField()
flavor_id = serializers.SerializerMethodField()
profile_owner_username = serializers.SerializerMethodField()
class Meta:
model = Notification
fields = [
"id",
"actor_username",
"actor_avatar",
"notification_type",
"rating",
"reply",
"ticket",
"profile_comment",
"profile_owner_username",
"is_read",
"created_at",
"flavor_name",
"flavor_id",
]
def get_actor_avatar(self, obj: Notification) -> str | None:
return absolute_image_url(self, obj.actor.avatar)
def get_flavor_name(self, obj: Notification) -> str | None:
if obj.rating:
return obj.rating.flavor.name
if obj.reply:
return obj.reply.rating.flavor.name
return None
def get_flavor_id(self, obj: Notification) -> int | None:
if obj.rating:
return obj.rating.flavor.id
if obj.reply:
return obj.reply.rating.flavor.id
return None
def get_profile_owner_username(self, obj: Notification) -> str | None:
if obj.profile_comment:
return obj.profile_comment.profile_owner.username
return None
+40
View File
@@ -0,0 +1,40 @@
from rest_framework import serializers
from api.models import Ticket, TicketMessage
from api.serializers._helpers import absolute_image_url
class TicketMessageSerializer(serializers.ModelSerializer):
username = serializers.CharField(source="user.username", read_only=True)
is_admin = serializers.BooleanField(source="user.is_superuser", read_only=True)
class Meta:
model = TicketMessage
fields = ["id", "username", "text", "created_at", "is_admin"]
class TicketSerializer(serializers.ModelSerializer):
username = serializers.CharField(source="user.username", read_only=True)
user_email = serializers.EmailField(source="user.email", read_only=True)
user_avatar = serializers.SerializerMethodField()
messages = TicketMessageSerializer(many=True, read_only=True)
class Meta:
model = Ticket
fields = [
"id",
"user",
"username",
"user_email",
"user_avatar",
"subject",
"description",
"status",
"created_at",
"updated_at",
"messages",
]
read_only_fields = ["status", "user"]
def get_user_avatar(self, obj: Ticket) -> str | None:
return absolute_image_url(self, obj.user.avatar)
+81
View File
@@ -0,0 +1,81 @@
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
@@ -0,0 +1,96 @@
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.
+14
View File
@@ -0,0 +1,14 @@
from rest_framework.request import Request
from api.models import User, UserIP
def log_user_ip(user: User, request: Request) -> None:
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
ip = x_forwarded_for.split(",")[0].strip()
else:
ip = request.META.get("REMOTE_ADDR")
if ip and user.is_authenticated:
UserIP.objects.update_or_create(user=user, ip_address=ip)
+29
View File
@@ -0,0 +1,29 @@
import re
from api.models import Notification, Rating, Reply, User
_MENTION_RE = re.compile(r"@(\w+)")
def parse_mentions(
text: str,
actor: User,
rating: Rating | None = None,
reply: Reply | None = None,
) -> None:
if not text:
return
for username in set(_MENTION_RE.findall(text)):
try:
recipient = User.objects.get(username__iexact=username)
except User.DoesNotExist:
continue
if recipient == actor:
continue
Notification.objects.get_or_create(
recipient=recipient,
actor=actor,
notification_type="mention",
rating=rating,
reply=reply,
)
+44
View File
@@ -0,0 +1,44 @@
from django.db.models import Q, QuerySet
from api.models import Flavor
CATEGORY_KEYWORDS: dict[str, str] = {
"iced tea": "iced-tea",
"eistee": "iced-tea",
"energy": "energy",
"hydration": "hydration",
"milkshake": "milkshake",
}
def extract_category_slug(query: str) -> tuple[str | None, str]:
lowered = query.lower().strip()
for keyword, slug in CATEGORY_KEYWORDS.items():
if keyword in lowered:
return slug, lowered.replace(keyword, "").strip()
return None, lowered
def filter_flavors_by_query(queryset: QuerySet[Flavor], query: str) -> QuerySet[Flavor]:
slug, remaining = extract_category_slug(query)
if slug:
queryset = queryset.filter(category__slug=slug)
if remaining:
word_query = Q()
for word in remaining.split():
if len(word) > 2:
word_query |= Q(name__icontains=word) | Q(description__icontains=word)
if word_query:
queryset = queryset.filter(word_query)
return queryset
def score_relevance(name: str, query: str) -> int:
name_lower = name.lower()
if name_lower == query:
return 3
if name_lower.startswith(query):
return 2
if query in name_lower:
return 1
return 0
+107
View File
@@ -0,0 +1,107 @@
"""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,
)
-45
View File
@@ -1,45 +0,0 @@
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from .models import Category, Flavor, User, Rating
class FlavorTopTests(APITestCase):
def setUp(self):
self.category_energy = Category.objects.create(name='Energy', slug='energy')
self.category_iced_tea = Category.objects.create(name='Iced Tea', slug='iced-tea')
self.user = User.objects.create_user(username='testuser', password='password')
# Create flavors
self.flavor1 = Flavor.objects.create(name='Energy 1', category=self.category_energy)
self.flavor2 = Flavor.objects.create(name='Iced Tea 1', category=self.category_iced_tea)
# Add ratings to make them appear in top (since we filter by ratings__isnull=False)
Rating.objects.create(user=self.user, flavor=self.flavor1, score=5)
Rating.objects.create(user=self.user, flavor=self.flavor2, score=4)
def test_top_flavors_all(self):
url = reverse('flavor-top')
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 2)
def test_top_flavors_filtered_energy(self):
url = reverse('flavor-top')
response = self.client.get(url, {'category': 'energy'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['name'], 'Energy 1')
def test_top_flavors_filtered_iced_tea(self):
url = reverse('flavor-top')
response = self.client.get(url, {'category': 'iced-tea'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['name'], 'Iced Tea 1')
def test_top_flavors_filtered_non_existent(self):
url = reverse('flavor-top')
response = self.client.get(url, {'category': 'non-existent'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 0)
View File
Whitespace-only changes.
+60
View File
@@ -0,0 +1,60 @@
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
@@ -0,0 +1,45 @@
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from api.models import Category, Flavor, Rating, User
class FlavorTopTests(APITestCase):
def setUp(self) -> None:
self.category_energy = Category.objects.create(name="Energy", slug="energy")
self.category_iced_tea = Category.objects.create(name="Iced Tea", slug="iced-tea")
self.user = User.objects.create_user(username="testuser", password="password")
self.flavor1 = Flavor.objects.create(name="Energy 1", category=self.category_energy)
self.flavor2 = Flavor.objects.create(name="Iced Tea 1", category=self.category_iced_tea)
Rating.objects.create(user=self.user, flavor=self.flavor1, score=5)
Rating.objects.create(user=self.user, flavor=self.flavor2, score=4)
def test_top_flavors_all(self) -> None:
url = reverse("flavor-top")
response = self.client.get(url, follow=True)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 2)
def test_top_flavors_filtered_energy(self) -> None:
url = reverse("flavor-top")
response = self.client.get(url, {"category": "energy"}, follow=True)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]["name"], "Energy 1")
def test_top_flavors_filtered_iced_tea(self) -> None:
url = reverse("flavor-top")
response = self.client.get(url, {"category": "iced-tea"}, follow=True)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]["name"], "Iced Tea 1")
def test_top_flavors_filtered_non_existent(self) -> None:
url = reverse("flavor-top")
response = self.client.get(url, {"category": "non-existent"}, follow=True)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 0)
+19
View File
@@ -0,0 +1,19 @@
from django.test import TestCase
from api.models import Category, Flavor, Rating, User
class FlavorAverageRatingTests(TestCase):
def setUp(self) -> None:
self.category = Category.objects.create(name="Energy", slug="energy")
self.flavor = Flavor.objects.create(name="Test", category=self.category)
self.u1 = User.objects.create_user(username="u1", password="x")
self.u2 = User.objects.create_user(username="u2", password="x")
def test_average_zero_when_no_ratings(self) -> None:
self.assertEqual(self.flavor.get_average_rating, 0.0)
def test_average_uses_aggregate(self) -> None:
Rating.objects.create(user=self.u1, flavor=self.flavor, score=4)
Rating.objects.create(user=self.u2, flavor=self.flavor, score=8)
self.assertEqual(self.flavor.get_average_rating, 6.0)
+12 -11
View File
@@ -1,18 +1,19 @@
from django.urls import path, include
from django.urls import include, path
from rest_framework import routers
from . import views
router = routers.DefaultRouter()
router.register('users', views.UserViewSet)
router.register('flavors', views.FlavorViewSet)
router.register('categories', views.CategoryViewSet)
router.register('ratings', views.RatingViewSet)
router.register('replies', views.ReplyViewSet)
router.register('notifications', views.NotificationViewSet, basename='notification')
router.register('tickets', views.TicketViewSet, basename='ticket')
router.register('admin-custom', views.AdminViewSet, basename='admin-custom')
router.register('banners', views.BannerViewSet, basename='banner')
router.register("users", views.UserViewSet)
router.register("flavors", views.FlavorViewSet)
router.register("categories", views.CategoryViewSet)
router.register("ratings", views.RatingViewSet)
router.register("replies", views.ReplyViewSet)
router.register("notifications", views.NotificationViewSet, basename="notification")
router.register("tickets", views.TicketViewSet, basename="ticket")
router.register("admin-custom", views.AdminViewSet, basename="admin-custom")
router.register("banners", views.BannerViewSet, basename="banner")
urlpatterns = [
path('', include(router.urls)),
path("", include(router.urls)),
]
View File
Whitespace-only changes.
+26
View File
@@ -0,0 +1,26 @@
"""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
@@ -0,0 +1,72 @@
"""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)
-979
View File
@@ -1,979 +0,0 @@
from django.core.mail import send_mail
from django.conf import settings
from rest_framework import viewsets, permissions, status, filters, serializers
from rest_framework.response import Response
from rest_framework.decorators import action
from django.db.models import Avg, Q
from django_filters.rest_framework import DjangoFilterBackend
from django_ratelimit.decorators import ratelimit
from django.utils.decorators import method_decorator
from rest_framework.pagination import PageNumberPagination
from .models import User, Flavor, Category, Rating, Reply, Notification, Ticket, TicketMessage, UserIP, ProfileComment, Banner, SystemConfig, Job
from .serializers import (
UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer,
ReplySerializer, NotificationSerializer, TicketSerializer,
TicketMessageSerializer, AdminUserListSerializer, AdminUserDetailSerializer,
ProfileCommentSerializer, BannerSerializer, SystemConfigSerializer, JobSerializer
)
def log_user_ip(user, request):
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
if ip and user.is_authenticated:
UserIP.objects.update_or_create(user=user, ip_address=ip)
def parse_mentions(text, actor, rating=None, reply=None):
import re
mentions = re.findall(r'@(\w+)', text)
for username in set(mentions):
try:
recipient = User.objects.get(username__iexact=username)
if recipient != actor:
Notification.objects.get_or_create(
recipient=recipient,
actor=actor,
notification_type='mention',
rating=rating,
reply=reply
)
except User.DoesNotExist:
continue
class FeedPagination(PageNumberPagination):
page_size = 10
class FlavorViewSet(viewsets.ModelViewSet):
queryset = Flavor.objects.annotate(average_rating=Avg('ratings__score')).order_by('-average_rating')
serializer_class = FlavorSerializer
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
filterset_fields = ['category', 'category__slug']
search_fields = ['name', 'description']
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
def get_queryset(self):
qs = Flavor.objects.select_related('category').annotate(average_rating=Avg('ratings__score')).order_by('-average_rating').distinct()
# Handle category keywords in search query for main results too
search_query = self.request.query_params.get('search', '').lower().strip()
if search_query:
cat_keywords = {
'iced tea': 'iced-tea',
'eistee': 'iced-tea',
'energy': 'energy',
'hydration': 'hydration',
'milkshake': 'milkshake'
}
for keyword, slug in cat_keywords.items():
if keyword in search_query:
qs = qs.filter(category__slug=slug)
break # Only one category at a time
return qs
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
def search(self, request):
query = request.query_params.get('q', '').lower().strip()
if not query:
return Response([])
# Start with all flavors
flavors = Flavor.objects.select_related('category').distinct()
# Intelligent category filtering based on keywords
cat_keywords = {
'iced tea': 'iced-tea',
'eistee': 'iced-tea',
'energy': 'energy',
'hydration': 'hydration',
'milkshake': 'milkshake'
}
active_cat_slug = None
remaining_query = query
for keyword, slug in cat_keywords.items():
if keyword in query:
active_cat_slug = slug
remaining_query = remaining_query.replace(keyword, '').strip()
break
if active_cat_slug:
flavors = flavors.filter(category__slug=active_cat_slug)
# Filter by remaining query words in name or description
if remaining_query:
words = remaining_query.split()
# Construct a combined OR query for all words to be more permissive (fuzzy-ish)
word_query = Q()
for word in words:
if len(word) > 2: # Only filter by words longer than 2 chars to avoid noise
word_query |= Q(name__icontains=word) | Q(description__icontains=word)
if word_query:
flavors = flavors.filter(word_query)
# Limit results and deduplicate in Python to be safe against DB inconsistencies
results_list = []
seen_keys = set()
# Priority sorting in Python
search_lower = remaining_query.lower()
# Order by name, but we'll also try to prioritize exact matches in Python
for f in flavors.order_by('name'):
# Create a unique key for this flavor (name + category)
key = f"{f.name.strip().lower()}|{f.category.slug}"
if key in seen_keys:
continue
seen_keys.add(key)
name_lower = f.name.lower()
relevance = 0
if name_lower == search_lower:
relevance = 3
elif name_lower.startswith(search_lower):
relevance = 2
elif search_lower in name_lower:
relevance = 1
results_list.append({
'id': f.id,
'name': f.name,
'type': 'flavor',
'subtitle': f.category.name,
'image_url': request.build_absolute_uri(f.image.url) if f.image else f.image_url,
'slug': None,
'relevance': relevance
})
# Sort by relevance desc, then name
results_list.sort(key=lambda x: (-x['relevance'], x['name']))
# Remove relevance helper and limit to 15
final_results = []
for r in results_list:
del r['relevance']
final_results.append(r)
if len(final_results) >= 15:
break
return Response(final_results)
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
def top(self, request):
queryset = self.get_queryset().filter(ratings__isnull=False)
category_slug = request.query_params.get('category')
if category_slug:
queryset = queryset.filter(category__slug=category_slug)
top_flavors = queryset.distinct()[:10]
serializer = self.get_serializer(top_flavors, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
def newest(self, request):
# Get the 10 newest flavors
newest_flavors = Flavor.objects.select_related('category').order_by('-created_at')[:10]
serializer = self.get_serializer(newest_flavors, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
def followed_top(self, request):
followed_users = request.user.following.all()
top_flavors = Flavor.objects.filter(ratings__user__in=followed_users) \
.annotate(average_rating=Avg('ratings__score')) \
.order_by('-average_rating') \
.distinct()[:10]
serializer = self.get_serializer(top_flavors, many=True)
return Response(serializer.data)
class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Category.objects.all()
serializer_class = CategorySerializer
class RatingViewSet(viewsets.ModelViewSet):
queryset = Rating.objects.all()
serializer_class = RatingSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
@method_decorator(ratelimit(key='user', rate='10/m', method='POST', block=True))
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
def perform_create(self, serializer):
# Ensure user can only have one rating per flavor
flavor = serializer.validated_data['flavor']
if Rating.objects.filter(user=self.request.user, flavor=flavor).exists():
raise serializers.ValidationError('You have already rated this flavor.')
rating = serializer.save(user=self.request.user)
# Parse mentions in the rating comment
if rating.comment:
parse_mentions(rating.comment, self.request.user, rating=rating)
def get_queryset(self):
return Rating.objects.select_related('user', 'flavor', 'flavor__category').prefetch_related('replies', 'replies__user').order_by('-created_at')
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
def feed(self, request):
# Get ratings from people the user follows
followed_users = request.user.following.all()
feed_ratings = Rating.objects.filter(user__in=followed_users).select_related('user', 'flavor', 'flavor__category').prefetch_related('replies', 'replies__user').order_by('-created_at')
paginator = FeedPagination()
page = paginator.paginate_queryset(feed_ratings, request)
if page is not None:
serializer = self.get_serializer(page, many=True)
return paginator.get_paginated_response(serializer.data)
serializer = self.get_serializer(feed_ratings, many=True)
return Response(serializer.data)
def check_object_permissions(self, request, obj):
super().check_object_permissions(request, obj)
if request.method in ['PUT', 'PATCH', 'DELETE'] and obj.user != request.user and not request.user.is_superuser:
self.permission_denied(request, message='You cannot edit/delete this rating.')
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
def recent(self, request):
recent_ratings = Rating.objects.filter(comment__isnull=False).exclude(comment='').select_related('user', 'flavor').order_by('-created_at')[:10]
serializer = self.get_serializer(recent_ratings, many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def reply(self, request, pk=None):
rating = self.get_object()
text = request.data.get('text')
if not text:
return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST)
reply = Reply.objects.create(user=request.user, rating=rating, text=text)
# Notify the rating author if it's not the same person
if rating.user != request.user:
Notification.objects.create(
recipient=rating.user,
actor=request.user,
notification_type='reply',
rating=rating,
reply=reply
)
# Parse mentions in the reply text
parse_mentions(text, request.user, rating=rating, reply=reply)
return Response(ReplySerializer(reply, context={'request': request}).data, status=status.HTTP_201_CREATED)
class ReplyViewSet(viewsets.ModelViewSet):
queryset = Reply.objects.all()
serializer_class = ReplySerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Reply.objects.select_related('user', 'rating').order_by('created_at')
def perform_update(self, serializer):
reply = self.get_object()
if reply.user != self.request.user and not self.request.user.is_superuser:
raise permissions.PermissionDenied("You cannot edit this reply.")
new_reply = serializer.save()
# Parse mentions in the updated text
parse_mentions(new_reply.text, self.request.user, rating=new_reply.rating, reply=new_reply)
def perform_destroy(self, instance):
if instance.user != self.request.user and not self.request.user.is_superuser:
raise permissions.PermissionDenied("You cannot delete this reply.")
instance.delete()
class NotificationViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = NotificationSerializer
permission_classes = [permissions.IsAuthenticated]
pagination_class = None
def get_queryset(self):
return self.request.user.notifications.select_related('actor', 'rating__flavor', 'reply__rating__flavor').order_by('-created_at')
@action(detail=False, methods=['post'])
def mark_all_read(self, request):
request.user.notifications.filter(is_read=False).update(is_read=True)
return Response({'status': 'all read'})
@action(detail=True, methods=['post'])
def mark_read(self, request, pk=None):
notification = self.get_object()
notification.is_read = True
notification.save()
return Response({'status': 'marked read'})
class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all()
serializer_class = UserSerializer
permission_classes = [permissions.IsAuthenticated]
def get_permissions(self):
if self.action in ['signup', 'verify_signup', 'request_password_reset', 'complete_password_reset']:
return [permissions.AllowAny()]
return super().get_permissions()
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
def following_list(self, request):
following = request.user.following.all()
serializer = self.get_serializer(following, many=True)
return Response(serializer.data)
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
@method_decorator(ratelimit(key='ip', rate='5/h', method='POST', block=True))
def signup(self, request):
username = request.data.get('username')
email = request.data.get('email')
password = request.data.get('password')
if not username or not email or not password:
return Response({'error': 'Username, email and password required'}, status=status.HTTP_400_BAD_REQUEST)
if User.objects.filter(username=username).exists():
return Response({'error': 'Username already exists'}, status=status.HTTP_400_BAD_REQUEST)
if User.objects.filter(email=email).exists():
return Response({'error': 'Email already exists'}, status=status.HTTP_400_BAD_REQUEST)
import secrets
import string
code = ''.join(secrets.choice(string.digits) for _ in range(6))
user = User.objects.create_user(
username=username,
email=email,
password=password,
is_active=False,
email_confirmation_code=code
)
verification_link = f"{settings.FRONTEND_URL}/verify-email?username={username}&code={code}"
try:
send_mail(
'Verify your Holy Flavors account',
f'Hi {username},\n\nYour verification code is: {code}\n\nAlternatively, you can complete your registration by clicking the link below:\n{verification_link}\n\nWelcome to the archive!',
settings.DEFAULT_FROM_EMAIL,
[email],
fail_silently=False,
)
except Exception as e:
# If email fails for any reason, delete the user so they can try again
user.delete()
return Response(
{'error': f'Failed to send verification email. Please check your SMTP settings in the .env file. The server error was: {e}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response({'status': 'User created, please verify your email', 'username': username, 'email': email}, status=status.HTTP_201_CREATED)
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
def resend_verification(self, request):
username = request.data.get('username')
if not username:
return Response({'error': 'Username required'}, status=status.HTTP_400_BAD_REQUEST)
try:
user = User.objects.get(username=username, is_active=False)
except User.DoesNotExist:
return Response({'error': 'User not found or already active'}, status=status.HTTP_404_NOT_FOUND)
import secrets
import string
code = ''.join(secrets.choice(string.digits) for _ in range(6))
user.email_confirmation_code = code
user.save()
verification_link = f"{settings.FRONTEND_URL}/verify-email?username={user.username}&code={code}"
try:
send_mail(
'Verify your Holy Flavors account',
f'Hi {user.username},\n\nYour new verification code is: {code}\n\nAlternatively, you can complete your registration by clicking the link below:\n{verification_link}',
settings.DEFAULT_FROM_EMAIL,
[user.email],
fail_silently=False,
)
except Exception as e:
return Response(
{'error': f'Failed to send email: {e}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response({'status': 'Verification code resent!'})
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
def verify_signup(self, request):
username = request.data.get('username')
code = request.data.get('code')
try:
user = User.objects.get(username=username, is_active=False)
except User.DoesNotExist:
return Response({'error': 'User not found or already active'}, status=status.HTTP_404_NOT_FOUND)
if user.email_confirmation_code == code:
user.is_active = True
user.email_confirmation_code = None
user.save()
return Response({'status': 'Account verified successfully!'})
else:
return Response({'error': 'Invalid verification code'}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny], url_path='profile/(?P<username>[^/.]+)')
def public_profile(self, request, username=None):
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
ratings = user.ratings.select_related('flavor', 'flavor__category').order_by('-score')
comments = user.profile_comments.select_related('author').order_by('-created_at')
followers = user.followers.all()
following = user.following.all()
return Response({
'id': user.id,
'username': user.username,
'theme': user.theme,
'avatar': request.build_absolute_uri(user.avatar.url) if user.avatar else None,
'following_count': following.count(),
'followers_count': followers.count(),
'is_following': request.user.following.filter(pk=user.pk).exists() if request.user.is_authenticated else False,
'ratings': RatingSerializer(ratings, many=True, context={'request': request}).data,
'comments': ProfileCommentSerializer(comments, many=True, context={'request': request}).data,
'followers': UserSerializer(followers, many=True, context={'request': request}).data,
'following': UserSerializer(following, many=True, context={'request': request}).data
})
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def add_comment(self, request, pk=None):
profile_owner = self.get_object()
text = request.data.get('text')
if not text:
return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST)
comment = ProfileComment.objects.create(
profile_owner=profile_owner,
author=request.user,
text=text
)
# Notify profile owner
if profile_owner != request.user:
Notification.objects.create(
recipient=profile_owner,
actor=request.user,
notification_type='profile_comment',
profile_comment=comment
)
return Response(ProfileCommentSerializer(comment, context={'request': request}).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['delete'], url_path='delete_comment/(?P<comment_id>[^/.]+)', permission_classes=[permissions.IsAuthenticated])
def delete_comment(self, request, pk=None, comment_id=None):
profile_owner = self.get_object()
try:
comment = ProfileComment.objects.get(pk=comment_id, profile_owner=profile_owner)
except ProfileComment.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
if comment.author != request.user and not request.user.is_superuser and profile_owner != request.user:
return Response(status=status.HTTP_403_FORBIDDEN)
comment.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=['get'])
def me(self, request):
log_user_ip(request.user, request)
serializer = self.get_serializer(request.user)
return Response(serializer.data)
@action(detail=False, methods=['patch'])
def update_preferences(self, request):
user = request.user
theme = request.data.get('theme')
language = request.data.get('language')
banner_id = request.data.get('selected_banner')
updated = False
if theme in dict(User.THEME_CHOICES):
user.theme = theme
updated = True
if language in dict(User.LANGUAGE_CHOICES):
user.language = language
updated = True
if banner_id is not None:
if banner_id == "":
user.selected_banner = None
updated = True
else:
try:
banner = Banner.objects.get(pk=banner_id, is_enabled=True)
user.selected_banner = banner
updated = True
except Banner.DoesNotExist:
return Response({'error': 'Invalid or disabled banner'}, status=status.HTTP_400_BAD_REQUEST)
if updated:
user.save()
return Response({
'status': 'preferences updated',
'theme': user.theme,
'language': user.language,
'selected_banner': user.selected_banner.id if user.selected_banner else None
})
return Response({'error': 'No valid updates provided'}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=False, methods=['post'])
def update_avatar(self, request):
user = request.user
avatar = request.FILES.get('avatar')
if not avatar:
return Response({'error': 'No avatar provided'}, status=status.HTTP_400_BAD_REQUEST)
# Check size (2MB = 2 * 1024 * 1024 bytes)
if avatar.size > 2 * 1024 * 1024:
return Response({'error': 'File size exceeds 2MB limit'}, status=status.HTTP_400_BAD_REQUEST)
user.avatar = avatar
user.save()
serializer = self.get_serializer(user)
return Response(serializer.data)
@action(detail=False, methods=['post'])
def change_password(self, request):
user = request.user
old_password = request.data.get('old_password')
new_password = request.data.get('new_password')
if not user.check_password(old_password):
return Response({'error': 'Wrong old password'}, status=status.HTTP_400_BAD_REQUEST)
user.set_password(new_password)
user.save()
return Response({'status': 'password changed'})
@action(detail=False, methods=['patch'])
def update_profile(self, request):
user = request.user
username = request.data.get('username')
email = request.data.get('email')
message = ""
if username and username != user.username:
if User.objects.filter(username=username).exists():
return Response({'error': 'Username already taken'}, status=status.HTTP_400_BAD_REQUEST)
user.username = username
if email and email != user.email:
import secrets
import string
code = ''.join(secrets.choice(string.digits) for _ in range(6))
user.pending_email = email
user.email_confirmation_code = code
send_mail(
'Confirm your new email',
f'Hi {user.username},\n\nYour confirmation code is: {code}',
settings.DEFAULT_FROM_EMAIL,
[email],
fail_silently=False,
)
message = " Confirmation code sent to new email."
user.save()
serializer = self.get_serializer(user)
data = serializer.data
if message:
data['message'] = message
return Response(data)
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def follow(self, request, pk=None):
user_to_follow = self.get_object()
if user_to_follow == request.user:
return Response({'error': 'You cannot follow yourself'}, status=status.HTTP_400_BAD_REQUEST)
if not request.user.following.filter(pk=user_to_follow.pk).exists():
request.user.following.add(user_to_follow)
# Notify the user being followed
Notification.objects.create(
recipient=user_to_follow,
actor=request.user,
notification_type='follow'
)
return Response({'status': 'followed'})
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def unfollow(self, request, pk=None):
user_to_unfollow = self.get_object()
request.user.following.remove(user_to_unfollow)
return Response({'status': 'unfollowed'})
@action(detail=False, methods=['post'])
def confirm_email(self, request):
user = request.user
code = request.data.get('code')
if not user.email_confirmation_code or code != user.email_confirmation_code:
return Response({'error': 'Invalid or expired code'}, status=status.HTTP_400_BAD_REQUEST)
user.email = user.pending_email
user.pending_email = None
user.email_confirmation_code = None
user.save()
return Response({'status': 'Email confirmed', 'email': user.email})
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
@method_decorator(ratelimit(key='ip', rate='3/h', method='POST', block=True))
def request_password_reset(self, request):
email = request.data.get('email')
try:
user = User.objects.get(email=email, is_active=True)
except User.DoesNotExist:
return Response({'status': 'If an account exists with this email, a reset code has been sent.'})
import secrets
import string
code = ''.join(secrets.choice(string.digits) for _ in range(6))
user.email_confirmation_code = code
user.save()
send_mail(
'Password Reset Request',
f'Hi {user.username},\n\nYour password reset code is: {code}',
settings.DEFAULT_FROM_EMAIL,
[email],
fail_silently=False,
)
return Response({'status': 'If an account exists with this email, a reset code has been sent.'})
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
@method_decorator(ratelimit(key='ip', rate='5/h', method='POST', block=True))
def complete_password_reset(self, request):
email = request.data.get('email')
code = request.data.get('code')
new_password = request.data.get('password')
try:
user = User.objects.get(email=email, email_confirmation_code=code)
except User.DoesNotExist:
return Response({'error': 'Invalid email or code'}, status=status.HTTP_400_BAD_REQUEST)
user.set_password(new_password)
user.email_confirmation_code = None
user.save()
return Response({'status': 'Password reset successful!'})
@action(detail=False, methods=['post'])
@method_decorator(ratelimit(key='user', rate='3/h', method='POST', block=True))
def request_account_deletion(self, request):
import secrets
import string
code = ''.join(secrets.choice(string.digits) for _ in range(6))
user = request.user
user.email_confirmation_code = code
user.save()
send_mail(
'Confirm Account Deletion - Holy Flavors Archive',
f'Hi {user.username},\n\nYou requested to delete your account. This action is permanent.\n\nYour deletion code is: {code}',
settings.DEFAULT_FROM_EMAIL,
[user.email],
fail_silently=False,
)
return Response({'status': 'Code sent to your email'})
@action(detail=False, methods=['post'])
def confirm_account_deletion(self, request):
user = request.user
code = request.data.get('code')
if not user.email_confirmation_code or code != user.email_confirmation_code:
return Response({'error': 'Invalid or expired code'}, status=status.HTTP_400_BAD_REQUEST)
user.delete()
return Response({'status': 'Account deleted'})
@action(detail=False, methods=['get'])
def dashboard(self, request):
user = request.user
rated_ids = user.ratings.values_list('flavor_id', flat=True)
followed_users = user.following.all()
# Missing flavors with community avg and followed circle avg
missing_flavors = Flavor.objects.exclude(id__in=rated_ids) \
.exclude(category__name='Packs and other') \
.select_related('category') \
.annotate(
average_rating=Avg('ratings__score'),
followed_average_rating=Avg('ratings__score', filter=Q(ratings__user__in=followed_users))
).order_by('category__name', 'name')
rated_flavors = user.ratings.select_related('flavor', 'flavor__category') \
.prefetch_related('replies', 'replies__user') \
.order_by('-created_at')
return Response({
'user': UserSerializer(user).data,
'rated_count': rated_flavors.count(),
'missing_count': missing_flavors.count(),
'missing_flavors': FlavorSerializer(missing_flavors, many=True, context={'request': request}).data,
'my_ratings': RatingSerializer(rated_flavors, many=True, context={'request': request}).data
})
class TicketViewSet(viewsets.ModelViewSet):
serializer_class = TicketSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
if self.request.user.is_superuser:
return Ticket.objects.all().prefetch_related('messages', 'messages__user').order_by('-updated_at')
return Ticket.objects.filter(user=self.request.user).prefetch_related('messages', 'messages__user').order_by('-updated_at')
def perform_create(self, serializer):
ticket = serializer.save(user=self.request.user)
# Notify all superusers about new ticket
admins = User.objects.filter(is_superuser=True).exclude(pk=self.request.user.pk)
for admin in admins:
Notification.objects.create(
recipient=admin,
actor=self.request.user,
notification_type='ticket_new',
ticket=ticket
)
@action(detail=True, methods=['post'])
def add_message(self, request, pk=None):
ticket = self.get_object()
text = request.data.get('text')
if not text:
return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST)
msg = TicketMessage.objects.create(ticket=ticket, user=request.user, text=text)
# Notification logic
if request.user.is_superuser:
# Notify the user who opened the ticket
if ticket.user != request.user:
Notification.objects.create(
recipient=ticket.user,
actor=request.user,
notification_type='ticket_reply',
ticket=ticket
)
else:
# Notify all superusers
admins = User.objects.filter(is_superuser=True).exclude(pk=request.user.pk)
for admin in admins:
Notification.objects.create(
recipient=admin,
actor=request.user,
notification_type='ticket_reply',
ticket=ticket
)
return Response(TicketMessageSerializer(msg).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAdminUser])
def update_status(self, request, pk=None):
ticket = self.get_object()
new_status = request.data.get('status')
if new_status in dict(Ticket.STATUS_CHOICES):
ticket.status = new_status
ticket.save()
return Response({'status': 'updated'})
return Response({'error': 'Invalid status'}, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, *args, **kwargs):
if not request.user.is_superuser:
raise permissions.PermissionDenied("Only admins can delete tickets.")
return super().destroy(request, *args, **kwargs)
class BannerViewSet(viewsets.ModelViewSet):
queryset = Banner.objects.all()
serializer_class = BannerSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
pagination_class = None
def get_queryset(self):
if self.request.user.is_authenticated and self.request.user.is_superuser:
return Banner.objects.all()
return Banner.objects.filter(is_enabled=True)
def get_permissions(self):
if self.action in ['list', 'retrieve', 'active']:
return [permissions.AllowAny()]
return [permissions.IsAdminUser()]
@action(detail=False, methods=['get'])
def active(self, request):
# Determine which banner to show
# Priority:
# 1. Requested user's preference (if username provided in query)
# 2. Logged in user's preference
# 3. Global default
username = request.query_params.get('username')
user = None
if username:
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
pass
if not user and request.user.is_authenticated:
user = request.user
if user and user.selected_banner and user.selected_banner.is_enabled:
serializer = self.get_serializer(user.selected_banner)
return Response(serializer.data)
active_banner = Banner.objects.filter(is_active=True).first()
if active_banner:
serializer = self.get_serializer(active_banner)
return Response(serializer.data)
return Response(None)
@action(detail=True, methods=['post'])
def activate(self, request, pk=None):
banner = self.get_object()
banner.is_active = True
banner.is_enabled = True # Active banner must be enabled
banner.save()
return Response({'status': 'banner set as global default'})
@action(detail=True, methods=['post'])
def toggle_enabled(self, request, pk=None):
banner = self.get_object()
if banner.is_active:
return Response({'error': 'Cannot disable the global default banner.'}, status=status.HTTP_400_BAD_REQUEST)
banner.is_enabled = not banner.is_enabled
banner.save()
return Response({'status': 'enabled' if banner.is_enabled else 'disabled'})
class AdminViewSet(viewsets.ViewSet):
permission_classes = [permissions.IsAdminUser]
@action(detail=False, methods=['get'])
def stats(self, request):
return Response({
'total_users': User.objects.count(),
'total_ratings': Rating.objects.count(),
'total_replies': Reply.objects.count(),
'open_tickets': Ticket.objects.filter(status='open').count(),
'email_config': {
'host': getattr(settings, 'EMAIL_HOST', 'None'),
'port': getattr(settings, 'EMAIL_PORT', 'None'),
'use_tls': getattr(settings, 'EMAIL_USE_TLS', False),
'use_ssl': getattr(settings, 'EMAIL_USE_SSL', False),
'skip_verify': getattr(settings, 'EMAIL_SKIP_CERT_VERIFICATION', False),
},
'server_info': {
'debug': settings.DEBUG,
'allowed_hosts': settings.ALLOWED_HOSTS,
'frontend_url': getattr(settings, 'FRONTEND_URL', 'Not set'),
'media_root': str(settings.MEDIA_ROOT),
'static_root': str(settings.STATIC_ROOT),
}
})
@action(detail=False, methods=['get', 'patch'])
def config(self, request):
config = SystemConfig.get_solo()
if request.method == 'PATCH':
serializer = SystemConfigSerializer(config, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
serializer = SystemConfigSerializer(config)
return Response(serializer.data)
@action(detail=False, methods=['get'])
def jobs(self, request):
# Ensure all jobs exist
for job_type, label in Job.JOB_TYPES:
Job.objects.get_or_create(name=job_type)
jobs = Job.objects.all().order_by('name')
serializer = JobSerializer(jobs, many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def trigger_job(self, request, pk=None):
try:
job = Job.objects.get(pk=pk)
job.status = 'pending'
job.save()
return Response({'status': 'Job queued'})
except Job.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
@action(detail=True, methods=['patch'])
def update_job_schedule(self, request, pk=None):
try:
job = Job.objects.get(pk=pk)
interval = request.data.get('interval_hours')
next_run = request.data.get('next_run')
if interval is not None:
job.interval_hours = int(interval)
if next_run is not None:
from django.utils.dateparse import parse_datetime
from django.utils import timezone
parsed_dt = parse_datetime(next_run)
if parsed_dt:
# If it's naive (has no timezone info), assume it's local time
if timezone.is_naive(parsed_dt):
job.next_run = timezone.make_aware(parsed_dt, timezone.get_current_timezone())
else:
job.next_run = parsed_dt
elif interval is not None and job.interval_hours > 0 and not job.next_run:
from django.utils import timezone
job.next_run = timezone.now()
job.save()
return Response(JobSerializer(job).data)
except Job.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
@action(detail=False, methods=['post'])
def send_test_email(self, request):
try:
send_mail(
'Holy Flavors Admin Test Email',
f'This is a test email sent to {request.user.email} from the Holy Flavors Admin Interface.',
settings.DEFAULT_FROM_EMAIL,
[request.user.email],
fail_silently=False,
)
return Response({'status': 'Test email sent!'})
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=False, methods=['get'])
def users(self, request):
users = User.objects.all().prefetch_related('ips').order_by('-date_joined')
return Response(AdminUserListSerializer(users, many=True).data)
@action(detail=True, methods=['get', 'patch', 'delete'])
def user_detail(self, request, pk=None):
try:
user = User.objects.get(pk=pk)
except User.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
if request.method == 'GET':
return Response(AdminUserDetailSerializer(user, context={'request': request}).data)
elif request.method == 'PATCH':
is_active = request.data.get('is_active')
if is_active is not None:
user.is_active = is_active
user.save()
return Response(AdminUserDetailSerializer(user, context={'request': request}).data)
elif request.method == 'DELETE':
user.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+20
View File
@@ -0,0 +1,20 @@
from api.views._pagination import FeedPagination
from api.views.admin import AdminViewSet
from api.views.banner import BannerViewSet
from api.views.flavor import CategoryViewSet, FlavorViewSet
from api.views.rating import RatingViewSet, ReplyViewSet
from api.views.support import TicketViewSet
from api.views.user import NotificationViewSet, UserViewSet
__all__ = [
"AdminViewSet",
"BannerViewSet",
"CategoryViewSet",
"FeedPagination",
"FlavorViewSet",
"NotificationViewSet",
"RatingViewSet",
"ReplyViewSet",
"TicketViewSet",
"UserViewSet",
]
+5
View File
@@ -0,0 +1,5 @@
from rest_framework.pagination import PageNumberPagination
class FeedPagination(PageNumberPagination):
page_size = 10
+195
View File
@@ -0,0 +1,195 @@
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 Job, Rating, Reply, SystemConfig, Ticket, User
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 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
@@ -0,0 +1,65 @@
"""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
@@ -0,0 +1,65 @@
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from api.models import Banner, User
from api.serializers import BannerSerializer
class BannerViewSet(viewsets.ModelViewSet):
queryset = Banner.objects.all()
serializer_class = BannerSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
pagination_class = None
def get_queryset(self):
if self.request.user.is_authenticated and self.request.user.is_superuser:
return Banner.objects.all()
return Banner.objects.filter(is_enabled=True)
def get_permissions(self):
if self.action in ["list", "retrieve", "active"]:
return [permissions.AllowAny()]
return [permissions.IsAdminUser()]
@action(detail=False, methods=["get"])
def active(self, request: Request) -> Response:
username = request.query_params.get("username")
user = None
if username:
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
pass
if not user and request.user.is_authenticated:
user = request.user
if user and user.selected_banner and user.selected_banner.is_enabled:
return Response(self.get_serializer(user.selected_banner).data)
active_banner = Banner.objects.filter(is_active=True).first()
if active_banner:
return Response(self.get_serializer(active_banner).data)
return Response(None)
@action(detail=True, methods=["post"])
def activate(self, request: Request, pk=None) -> Response:
banner = self.get_object()
banner.is_active = True
banner.is_enabled = True
banner.save()
return Response({"status": "banner set as global default"})
@action(detail=True, methods=["post"])
def toggle_enabled(self, request: Request, pk=None) -> Response:
banner = self.get_object()
if banner.is_active:
return Response(
{"error": "Cannot disable the global default banner."},
status=status.HTTP_400_BAD_REQUEST,
)
banner.is_enabled = not banner.is_enabled
banner.save()
return Response({"status": "enabled" if banner.is_enabled else "disabled"})
+106
View File
@@ -0,0 +1,106 @@
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
@@ -0,0 +1,126 @@
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
@@ -0,0 +1,87 @@
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
@@ -0,0 +1,562 @@
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"})
+27 -28
View File
@@ -1,47 +1,46 @@
#!/bin/sh
# Exit on error
set -e
# Apply database migrations
# 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 makemigrations
python manage.py migrate
python manage.py migrate --noinput
# Create cache table for ratelimiting
echo "Creating cache table..."
python manage.py createcachetable
# Collect static files
echo "Collecting static files..."
python manage.py collectstatic --no-input
# Ensure media and cache directories exist
mkdir -p media/flavors
mkdir -p django_cache
chmod -R 777 django_cache
# Sync flavors from the API
echo "Syncing flavors..."
python manage.py sync_flavors
# Merge any duplicates that might have been created
echo "Cleaning up duplicates..."
python manage.py cleanup_duplicates
# Seed legacy flavors if needed
echo "Seeding legacy flavors..."
python manage.py seed_legacy_flavors
# Auto-sync banner models and settings from JSON files
echo "Syncing banner configurations..."
python manage.py seed_banners
# 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
# Start the server using Gunicorn
echo "Starting Gunicorn..."
exec gunicorn holy_backend.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 120
+3
View File
@@ -0,0 +1,3 @@
from holy_backend.celery import app as celery_app
__all__ = ("celery_app",)
+1 -1
View File
@@ -11,6 +11,6 @@ import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'holy_backend.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
application = get_asgi_application()
+15
View File
@@ -0,0 +1,15 @@
"""Celery application for holy_backend.
Autodiscovers tasks from installed Django apps. Broker/backend configuration
is read from Django settings (CELERY_* namespace).
"""
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
app = Celery("holy_backend")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
-231
View File
@@ -1,231 +0,0 @@
"""
Django settings for holy_backend project.
Generated by 'django-admin startproject' using Django 6.0.2.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/6.0/ref/settings/
"""
from pathlib import Path
import os
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-zos99ue2a89!4jrv4g@_b4nn4z!5o*@8k!5cr9!u$j%_@xb=l8')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.environ.get('DEBUG', 'false').lower() == 'true'
# Simplification: Use a single DOMAIN variable to derive multiple settings
DOMAIN = os.environ.get('DOMAIN', 'localhost')
# Helper to build lists from env or defaults
def get_env_list(var_name, default_list):
val = os.environ.get(var_name)
if val:
return [item.strip() for item in val.split(',')]
return default_list
ALLOWED_HOSTS = get_env_list('ALLOWED_HOSTS', ['localhost', '127.0.0.1', 'backend', 'worker', DOMAIN])
# Application definition
INSTALLED_APPS = [
'whitenoise.runserver_nostatic',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Third party
'rest_framework',
'rest_framework.authtoken',
'corsheaders',
'django_filters',
'drf_spectacular',
'django_ratelimit',
# Local
'api',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'compression_middleware.middleware.CompressionMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'holy_backend.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'holy_backend.wsgi.application'
# Database
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Cache configuration for Ratelimiting
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.db.DatabaseCache',
'LOCATION': 'django_ratelimit_cache',
}
}
# Silence ratelimit warnings for non-Redis/Memcached backends
SILENCED_SYSTEM_CHECKS = ['django_ratelimit.W001', 'django_ratelimit.E003']
# Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/6.0/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_STORAGE = 'whitenoise.storage.CompressedStaticFilesStorage'
WHITENOISE_INDEX_PAGE = True
MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media'
# --- Reverse Proxy Configuration ---
# These settings are crucial for running behind a proxy like Traefik
USE_X_FORWARDED_HOST = True
USE_X_FORWARDED_PORT = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# Production Security
if not DEBUG:
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
# Read CSRF trusted origins from environment variable
CSRF_TRUSTED_ORIGINS = get_env_list('CSRF_TRUSTED_ORIGINS', [f'https://{DOMAIN}', 'http://localhost:5173'])
# Custom User Model
AUTH_USER_MODEL = 'api.User'
# REST Framework
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
],
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 50,
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
}
# CORS
CORS_ALLOWED_ORIGINS = get_env_list('CORS_ALLOWED_ORIGINS', [f'https://{DOMAIN}', 'http://localhost:5173', 'http://127.0.0.1:5173'])
FRONTEND_URL = os.environ.get('FRONTEND_URL', f'https://{DOMAIN}').rstrip('/')
import os
# ... (near top of file)
# Email (SMTP)
# For production, set these via environment variables.
# For development, it will default to printing emails to the console.
EMAIL_HOST = os.environ.get('EMAIL_HOST')
EMAIL_PORT = int(os.environ.get('EMAIL_PORT', 587))
# Default to False unless explicitly set to 'true' in the .env file
EMAIL_USE_TLS = os.environ.get('EMAIL_USE_TLS', 'false').lower() == 'true'
EMAIL_USE_SSL = os.environ.get('EMAIL_USE_SSL', 'false').lower() == 'true'
EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD')
EMAIL_SKIP_CERT_VERIFICATION = os.environ.get('EMAIL_SKIP_CERT_VERIFICATION', 'false').lower() == 'true'
DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', EMAIL_HOST_USER)
SERVER_EMAIL = DEFAULT_FROM_EMAIL
if EMAIL_HOST:
EMAIL_BACKEND = 'api.email_backend.InsecureEmailBackend'
else:
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# Swagger
SPECTACULAR_SETTINGS = {
'TITLE': 'Holy Flavors API',
'DESCRIPTION': 'API for rating flavors',
'VERSION': '1.0.0',
'SERVE_INCLUDE_SCHEMA': False,
}
Whitespace-only changes.
+206
View File
@@ -0,0 +1,206 @@
"""
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
+18
View File
@@ -0,0 +1,18 @@
"""Development settings. Selected by default via manage.py / wsgi.py / asgi.py."""
import os
from holy_backend.settings.base import * # noqa: F401,F403
DEBUG = True
# Dev-only fallback. Override via SECRET_KEY env var if you want consistency
# across restarts (sessions invalidate when this changes).
SECRET_KEY = os.environ.get(
"SECRET_KEY",
"django-insecure-dev-only-do-not-use-in-production",
)
# Run Celery tasks synchronously in dev/tests unless a broker is available.
CELERY_TASK_ALWAYS_EAGER = os.environ.get("CELERY_TASK_ALWAYS_EAGER", "true").lower() == "true"
CELERY_TASK_EAGER_PROPAGATES = True
+23
View File
@@ -0,0 +1,23 @@
"""Production settings. Set DJANGO_SETTINGS_MODULE=holy_backend.settings.prod."""
import os
from holy_backend.settings.base import * # noqa: F401,F403
DEBUG = False
# Required in production. Fail loudly if not set.
try:
SECRET_KEY = os.environ["SECRET_KEY"]
except KeyError as exc:
raise RuntimeError("SECRET_KEY environment variable must be set in production.") from exc
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
JWT_AUTH_COOKIE_SECURE = True
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
+34 -22
View File
@@ -1,34 +1,46 @@
from django.contrib import admin
from django.urls import path, include
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
from rest_framework.authtoken.views import obtain_auth_token
from django.conf import settings
from django.conf.urls.static import static
from django.views.static import serve
from django.views.generic.base import TemplateView
from django.http import HttpResponse
import os
from django.conf import settings
from django.contrib import admin
from django.http import HttpResponse
from django.urls import include, path
from django.views.generic.base import TemplateView
from django.views.static import serve
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
from rest_framework_simplejwt.views import TokenVerifyView
from api.views.auth import CookieLogoutView, CookieTokenObtainPairView, CookieTokenRefreshView
def health_check(request):
return HttpResponse("OK", content_type="text/plain")
# Clean the admin path
admin_url = os.environ.get('ADMIN_URL', 'admin').strip('/')
admin_url = os.environ.get("ADMIN_URL", "admin").strip("/")
urlpatterns = [
# Health check
path('health/', health_check, name='health_check'),
path("health/", health_check, name="health_check"),
# Robots.txt
path('robots.txt', TemplateView.as_view(template_name="robots.txt", content_type="text/plain")),
path("robots.txt", TemplateView.as_view(template_name="robots.txt", content_type="text/plain")),
# Ensure admin ends with a slash for Django's redirection logic
path(f'{admin_url}/', admin.site.urls),
path('api/', include('api.urls')),
path('api/token/', obtain_auth_token, name='api_token'),
path('api-auth/', include('rest_framework.urls')),
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
path('media/<path:path>', serve, {'document_root': settings.MEDIA_ROOT}),
path(f"{admin_url}/", admin.site.urls),
path("api/", include("api.urls")),
path("api/auth/token/", CookieTokenObtainPairView.as_view(), name="token_obtain_pair"),
path("api/auth/token/refresh/", CookieTokenRefreshView.as_view(), name="token_refresh"),
path("api/auth/token/verify/", TokenVerifyView.as_view(), name="token_verify"),
path("api/auth/logout/", CookieLogoutView.as_view(), name="auth_logout"),
# Legacy alias: old DRF TokenAuth endpoint now sets cookies just like the
# canonical login route so clients relying on POST /api/token/ keep working.
path("api/token/", CookieTokenObtainPairView.as_view(), name="api_token_legacy"),
path("api-auth/", include("rest_framework.urls")),
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path(
"api/schema/swagger-ui/",
SpectacularSwaggerView.as_view(url_name="schema"),
name="swagger-ui",
),
path("api/schema/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
path("media/<path:path>", serve, {"document_root": settings.MEDIA_ROOT}),
]
+1 -1
View File
@@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'holy_backend.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
application = get_wsgi_application()
+3 -2
View File
@@ -1,12 +1,13 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'holy_backend.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
@@ -18,5 +19,5 @@ def main():
execute_from_command_line(sys.argv)
if __name__ == '__main__':
if __name__ == "__main__":
main()
+49
View File
@@ -0,0 +1,49 @@
[tool.ruff]
line-length = 100
target-version = "py312"
extend-exclude = ["migrations", "venv", ".venv"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"UP", # pyupgrade
"C4", # flake8-comprehensions
"DJ", # flake8-django
]
ignore = [
"E501", # line too long (formatter handles it)
"B008", # function call in default argument (common in DRF)
"DJ001", # null=True on string fields (pre-existing schema decisions)
"DJ008", # missing __str__ (not all models need one)
"DJ012", # model attribute ordering style
]
[tool.ruff.lint.per-file-ignores]
"api/migrations/*.py" = ["ALL"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
[tool.mypy]
python_version = "3.12"
plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"]
exclude = ["migrations/", "venv/", ".venv/"]
disallow_untyped_defs = false
check_untyped_defs = true
warn_unused_ignores = true
warn_redundant_casts = true
ignore_missing_imports = true
[tool.django-stubs]
django_settings_module = "holy_backend.settings.dev"
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "holy_backend.settings.dev"
python_files = ["test_*.py", "tests.py"]
testpaths = ["api/tests"]
addopts = "-ra --strict-markers"
+14
View File
@@ -0,0 +1,14 @@
-r requirements.txt
# Linting / formatting
ruff>=0.6.0
# Type checking
mypy>=1.11.0
django-stubs[compatible-mypy]>=5.0.0
djangorestframework-stubs[compatible-mypy]>=3.15.0
# Testing
pytest>=8.0.0
pytest-django>=4.8.0
pytest-cov>=5.0.0
+4
View File
@@ -10,3 +10,7 @@ gunicorn>=21.2.0
django-ratelimit>=4.1.0
django-compression-middleware>=0.5.0
requests>=2.31.0
celery[redis]>=5.4.0
django-celery-beat>=2.6.0
redis>=5.0.0
djangorestframework-simplejwt>=5.3.1
+2987
View File
File diff suppressed because it is too large. Load diff
+93 -3
View File
@@ -1,4 +1,23 @@
services:
# --- Redis (Celery broker + result backend) ---
redis:
image: redis:7-alpine
container_name: holy-redis
command: redis-server --appendonly yes --save 60 1
restart: unless-stopped
sysctls:
- net.core.somaxconn=512
- vm.overcommit_memory=1
networks:
- holy-net
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
# --- Backend Service ---
backend:
build:
@@ -8,7 +27,7 @@ services:
networks:
- holy-net
ports:
- "8000:8000"
- 8000
env_file:
- .env
volumes:
@@ -17,6 +36,12 @@ services:
environment:
- PYTHONUNBUFFERED=1
- DOMAIN=${DOMAIN:-localhost}
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-holy_backend.settings.prod}
- CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://redis:6379/0}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
depends_on:
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health/"]
interval: 10s
@@ -28,20 +53,84 @@ services:
build:
context: ./backend
container_name: holy-worker
command: python manage.py worker
command: celery -A holy_backend worker -l info --concurrency=2
user: "app"
restart: unless-stopped
networks:
- holy-net
env_file:
- .env
environment:
- PYTHONUNBUFFERED=1
- DOMAIN=${DOMAIN:-localhost}
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-holy_backend.settings.prod}
- CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://redis:6379/0}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
- SKIP_INIT=true
volumes:
- ./backend:/app
- ./legacy:/app/legacy
depends_on:
redis:
condition: service_healthy
backend:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "celery -A holy_backend inspect ping -d celery@$$HOSTNAME || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
beat:
build:
context: ./backend
container_name: holy-beat
command: celery -A holy_backend beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler --pidfile /tmp/celerybeat.pid
user: "app"
restart: unless-stopped
networks:
- holy-net
env_file:
- .env
environment:
- PYTHONUNBUFFERED=1
- DOMAIN=${DOMAIN:-localhost}
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-holy_backend.settings.prod}
- CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://redis:6379/0}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
- SKIP_INIT=true
volumes:
- ./backend:/app
- ./legacy:/app/legacy
depends_on:
redis:
condition: service_healthy
backend:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "test -f /tmp/celerybeat.pid && kill -0 $$(cat /tmp/celerybeat.pid) || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
flower:
image: mher/flower:2.0
container_name: holy-flower
restart: unless-stopped
command: celery --broker=${CELERY_BROKER_URL:-redis://redis:6379/0} flower --port=5555
environment:
- CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://redis:6379/0}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
- FLOWER_BASIC_AUTH=${FLOWER_BASIC_AUTH:-admin:change-me}
ports:
- 5555
networks:
- holy-net
depends_on:
redis:
condition: service_healthy
frontend:
build:
@@ -56,7 +145,7 @@ services:
container_name: holy-frontend
restart: unless-stopped
ports:
- "80:80"
- 80
networks:
- holy-net
env_file:
@@ -71,3 +160,4 @@ networks:
volumes:
db_data:
redis_data:
+5
View File
@@ -0,0 +1,5 @@
dist
node_modules
public
*.lock
package-lock.json
+8
View File
@@ -0,0 +1,8 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"semi": true,
"arrowParens": "always"
}
+13
View File
@@ -19,5 +19,18 @@ export default defineConfig([
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' },
],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-empty-object-type': 'warn',
'react-hooks/set-state-in-effect': 'warn',
'react-hooks/immutability': 'warn',
'react-hooks/purity': 'warn',
'react-refresh/only-export-components': 'warn',
'no-empty': 'warn',
},
},
])
+1571 -4
View File
File diff suppressed because it is too large. Load diff
+22 -2
View File
@@ -7,7 +7,15 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"typecheck": "tsc -b --noEmit",
"format": "prettier --write \"src/**/*.{ts,tsx,js,json,css}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,json,css}\"",
"test": "vitest run",
"test:watch": "vitest",
"preview": "vite preview",
"schema:export": "cd ../backend && ./venv/bin/python manage.py spectacular --file schema.yaml",
"openapi:gen": "openapi-typescript ../backend/schema.yaml -o src/api/schema.d.ts",
"openapi:sync": "npm run schema:export && npm run openapi:gen"
},
"dependencies": {
"@catppuccin/palette": "^1.7.1",
@@ -15,8 +23,11 @@
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.8",
"@mui/material": "^7.3.8",
"@tanstack/react-query": "^5.100.3",
"@tanstack/react-query-devtools": "^5.100.3",
"axios": "^1.13.5",
"colorthief": "^2.6.0",
"embla-carousel-react": "^8.6.0",
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.2",
"react": "^19.2.0",
@@ -26,17 +37,26 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/colorthief": "^2.6.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/ui": "^4.1.4",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^29.0.2",
"openapi-typescript": "^7.13.0",
"prettier": "^3.8.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
"vite": "^7.3.1",
"vitest": "^4.1.4"
}
}
+220 -581
View File
@@ -1,10 +1,6 @@
import React, { useState, useEffect, useMemo, Suspense, lazy } from 'react';
import { Routes, Route, Link, useNavigate, useLocation } from 'react-router-dom';
import React, { Suspense, lazy, useEffect, useState } from 'react';
import { Routes, Route, Link, useNavigate, Navigate } from 'react-router-dom';
import {
ThemeProvider,
CssBaseline,
AppBar,
Toolbar,
Typography,
Button,
Box,
@@ -12,38 +8,31 @@ import {
Avatar,
Menu,
MenuItem,
Drawer,
Divider,
List,
ListItem,
ListItemButton,
ListItemText,
Collapse,
TextField,
InputAdornment,
Autocomplete,
SwipeableDrawer,
CircularProgress,
ListItemAvatar,
ListSubheader,
Badge,
alpha,
useMediaQuery
useMediaQuery,
useTheme as useMuiTheme,
} from '@mui/material';
import { GlassAppBar } from './components/ui';
import MenuIcon from '@mui/icons-material/Menu';
import SearchIcon from '@mui/icons-material/Search';
import NotificationsIcon from '@mui/icons-material/Notifications';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import ExpandLess from '@mui/icons-material/ExpandLess';
import ExpandMore from '@mui/icons-material/ExpandMore';
import { getTheme, type CatppuccinTheme } from './theme';
import api from './api';
import './i18n';
import { useTranslation } from 'react-i18next';
import api from './lib/api';
import Footer from './components/Footer';
import { formatDate } from './utils/date';
import CookieBanner from './components/CookieBanner';
import { formatDate } from './utils/date';
import { GlobalSearch } from './app/GlobalSearch';
import { useAuth } from './hooks/useAuth';
import { useTheme } from './hooks/useTheme';
import { useNotifications } from './hooks/useNotifications';
import { useDrawerAnchor } from './hooks/useDrawerAnchor';
import { NavSidebar, NAV_SIDEBAR_WIDTH } from './components/layout/NavSidebar';
import { RequireSuperuser } from './components/auth/RequireSuperuser';
import type { Notification } from './contexts/NotificationContext';
import type { CatppuccinTheme } from './theme';
// Code splitting
const MainPage = lazy(() => import('./pages/MainPage'));
const CategoryFlavors = lazy(() => import('./pages/categories/CategoryFlavors'));
const FlavorDetail = lazy(() => import('./pages/flavors/FlavorDetail'));
@@ -56,432 +45,74 @@ const Login = lazy(() => import('./pages/Login'));
const Settings = lazy(() => import('./pages/Settings'));
const Support = lazy(() => import('./pages/Support'));
const VerifyEmail = lazy(() => import('./pages/VerifyEmail'));
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
const Forbidden = lazy(() => import('./pages/Forbidden'));
const AdminLayout = lazy(() => import('./pages/admin/AdminLayout'));
const AdminOverview = lazy(() => import('./pages/admin/AdminOverview'));
const AdminUsers = lazy(() => import('./pages/admin/AdminUsers'));
const AdminBanners = lazy(() => import('./pages/admin/AdminBanners'));
const AdminJobs = lazy(() => import('./pages/admin/AdminJobs'));
const AdminTickets = lazy(() => import('./pages/admin/AdminTickets'));
const AdminConfig = lazy(() => import('./pages/admin/AdminConfig'));
const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
const AdminRatingDetail = lazy(() => import('./pages/AdminRatingDetail'));
const AdminReplyDetail = lazy(() => import('./pages/AdminReplyDetail'));
interface SearchResult {
id: number;
name: string;
type: 'flavor' | 'category' | 'user';
subtitle: string;
image_url: string | null;
slug: string | null;
}
interface Notification {
id: number;
actor_username: string;
actor_avatar: string | null;
notification_type: 'reply' | 'mention' | 'follow' | 'ticket_new' | 'ticket_reply' | 'profile_comment';
rating: number | null;
reply: number | null;
is_read: boolean;
created_at: string;
flavor_name: string | null;
flavor_id: number | null;
profile_owner_username: string | null;
}
const GlobalSearch = () => {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [options, setOptions] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const isMobile = useMediaQuery('(max-width:1150px)');
// Debounced search
useEffect(() => {
if (!query.trim()) {
setOptions([]);
return;
}
const timer = setTimeout(async () => {
setLoading(true);
try {
const res = await api.get(`flavors/search/?q=${encodeURIComponent(query)}`);
const results = Array.isArray(res.data) ? res.data : (res.data.results || []);
setOptions(results);
} catch (err) {
console.error('Search failed');
} finally {
setLoading(false);
}
}, 300);
return () => clearTimeout(timer);
}, [query]);
useEffect(() => {
const params = new URLSearchParams(location.search);
const q = params.get('q');
if (q) setQuery(q);
}, [location.search]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (query.trim()) {
navigate(`/?q=${encodeURIComponent(query.trim())}`);
} else {
navigate('/');
}
};
return (
<Box sx={{ flexGrow: 1, mx: isMobile ? 1 : 4 }}>
<Autocomplete
fullWidth
freeSolo
size="small"
loading={loading}
options={options}
filterOptions={(x) => x}
getOptionLabel={(option) => {
if (typeof option === 'string') return option;
return option.name || '';
}}
inputValue={query}
onInputChange={(_, newValue) => setQuery(typeof newValue === 'string' ? newValue : '')}
onChange={(_, newValue) => {
if (newValue && typeof newValue !== 'string') {
if (newValue.type === 'flavor') navigate(`/flavor/${newValue.id}`);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSearch(e as any);
}
}}
renderInput={(params) => (
<TextField
{...params}
fullWidth
placeholder={t('common.search')}
sx={{
bgcolor: 'action.hover',
borderRadius: 2,
'& .MuiOutlinedInput-root': {
color: 'inherit',
'& fieldset': { border: 'none' },
'&:hover fieldset': { border: 'none' },
'&.Mui-focused fieldset': { border: 'none' },
outline: 'none'
},
'& .MuiInputBase-input': {
outline: 'none'
}
}}
InputProps={{
...params.InputProps,
startAdornment: (
<InputAdornment position="start">
<SearchIcon sx={{ color: 'inherit', opacity: 0.7 }} />
</InputAdornment>
),
endAdornment: (
<React.Fragment>
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</React.Fragment>
),
}}
/>
)}
renderOption={(props, option) => {
const { key, ...optionProps } = props as any;
return (
<Box component="li" key={key} {...optionProps} sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box
component="img"
src={option.image_url || undefined}
sx={{ width: 32, height: 32, borderRadius: 1, objectFit: 'contain', bgcolor: 'action.hover', p: 0.5 }}
/>
<Box>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{option.name}</Typography>
<Typography variant="caption" color="text.secondary">{option.subtitle}</Typography>
</Box>
</Box>
);
}}
/>
</Box>
);
};
const App: React.FC = () => {
const { i18n, t } = useTranslation();
const { t } = useTranslation();
const navigate = useNavigate();
const [themeName, setThemeName] = useState<CatppuccinTheme>((localStorage.getItem('theme') as CatppuccinTheme) || 'holy_light');
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const muiTheme = useMuiTheme();
const { user, loadingUser } = useAuth();
const { themeName, handleThemeChange } = useTheme();
const { notifications, markAllRead, markRead } = useNotifications();
const { anchor: mobileAnchor } = useDrawerAnchor();
const [notifAnchorEl, setNotifAnchorEl] = useState<null | HTMLElement>(null);
const [catAnchorEl, setCatAnchorEl] = useState<null | HTMLElement>(null);
const [categories, setCategories] = useState<{name: string, slug: string}[]>([]);
const [user, setUser] = useState<{username: string, avatar: string | null, unread_notifications_count: number, is_superuser: boolean} | null>(null);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [following, setFollowing] = useState<{id: number, username: string, avatar: string | null}[]>([]);
const [loadingUser, setLoadingUser] = useState(true);
const [categories, setCategories] = useState<{ name: string; slug: string }[]>([]);
const isMobile = useMediaQuery(muiTheme.breakpoints.down('md'));
const [drawerOpen, setDrawerOpen] = useState(false);
const [catOpen, setCatOpen] = useState(false);
const [adminMode, setAdminMode] = useState(localStorage.getItem('admin-mode') === 'true');
const isMobile = useMediaQuery('(max-width:1150px)');
const toggleAdminMode = () => {
const newVal = !adminMode;
setAdminMode(newVal);
localStorage.setItem('admin-mode', String(newVal));
};
const fetchNotifications = async () => {
try {
const res = await api.get('notifications/');
const data = Array.isArray(res.data) ? res.data : (res.data.results || []);
setNotifications(data);
} catch (err) {
console.error('Failed to fetch notifications');
}
};
useEffect(() => {
const fetchData = async () => {
try {
const token = localStorage.getItem('token');
const [userRes, catRes] = await Promise.all([
token ? api.get('users/me/') : Promise.resolve({ data: null }),
api.get('categories/')
]);
if (userRes.data && userRes.data.username) {
setUser(userRes.data);
if (userRes.data.theme) {
setThemeName(userRes.data.theme);
localStorage.setItem('theme', userRes.data.theme);
}
if (userRes.data.language) i18n.changeLanguage(userRes.data.language);
try {
const followRes = await api.get('users/following_list/');
const followData = Array.isArray(followRes.data) ? followRes.data : (followRes.data.results || []);
setFollowing(followData);
fetchNotifications();
} catch (err) {
console.error('Failed to fetch following list');
}
} else {
setUser(null);
}
const catData = Array.isArray(catRes.data) ? catRes.data : (catRes.data.results || []);
setCategories(catData);
} catch (err) {
setUser(null);
} finally {
setLoadingUser(false);
}
};
fetchData();
const interval = setInterval(() => {
if (localStorage.getItem('token')) {
fetchNotifications();
api.get('users/me/').then(res => setUser(res.data)).catch(() => {});
}
}, 60000);
return () => clearInterval(interval);
api
.get('categories/')
.then((res) => {
const data = Array.isArray(res.data) ? res.data : res.data.results || [];
setCategories(data);
})
.catch(() => setCategories([]));
}, []);
const theme = useMemo(() => getTheme(themeName), [themeName]);
useEffect(() => {
// Update body styles to match theme immediately (overriding index.html blocking script)
document.body.style.backgroundColor = theme.palette.background.default;
document.body.style.color = theme.palette.text.primary;
}, [theme]);
document.body.style.backgroundColor = muiTheme.palette.background.default;
document.body.style.color = muiTheme.palette.text.primary;
}, [muiTheme]);
const handleMarkAllRead = async () => {
try {
await api.post('notifications/mark_all_read/');
setNotifications(notifications.map(n => ({ ...n, is_read: true })));
if (user) setUser({ ...user, unread_notifications_count: 0 });
} catch (err) {
console.error('Failed to mark all as read');
}
const onThemeChange = async (name: CatppuccinTheme) => {
await handleThemeChange(name, !!user);
};
const handleNotificationClick = async (notif: Notification) => {
if (!notif.is_read) {
try {
await api.post(`notifications/${notif.id}/mark_read/`);
setNotifications(notifications.map(n => n.id === notif.id ? { ...n, is_read: true } : n));
if (user) setUser({ ...user, unread_notifications_count: Math.max(0, user.unread_notifications_count - 1) });
} catch (err) { /* ignore */ }
}
if (!notif.is_read) await markRead(notif.id);
setNotifAnchorEl(null);
if (notif.flavor_id) {
if (notif.notification_type.startsWith('ticket')) {
navigate(user?.is_superuser ? '/admin-panel/tickets' : '/support');
} else if (notif.notification_type === 'profile_comment') {
navigate(`/profile/${user?.username}`);
} else if (notif.notification_type === 'follow') {
navigate(`/profile/${notif.actor_username}`);
} else if (notif.flavor_id) {
window.location.href = `/flavor/${notif.flavor_id}`;
}
};
const handleThemeChange = async (newTheme: CatppuccinTheme) => {
setThemeName(newTheme);
localStorage.setItem('theme', newTheme);
if (user) {
try {
await api.patch('users/update_preferences/', { theme: newTheme });
} catch (err) {
console.error('Failed to update theme on server');
}
}
setAnchorEl(null);
};
const handleLogout = () => {
localStorage.removeItem('token');
setUser(null);
setFollowing([]);
i18n.changeLanguage(navigator.language.split('-')[0] || 'en');
window.location.href = '/';
};
const drawer = (
<Box sx={{ width: 280 }} role="presentation">
<Box sx={{ p: 2, display: 'flex', alignItems: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
<Link to="/" onClick={() => setDrawerOpen(false)} style={{ color: 'inherit', textDecoration: 'none' }}>
Holy Flavors Archive
</Link>
</Typography>
</Box>
<Divider />
<List>
<ListItem disablePadding>
<ListItemButton component={Link} to="/" onClick={() => setDrawerOpen(false)}>
<ListItemText primary={t('nav.home')} />
</ListItemButton>
</ListItem>
{user && (
<ListItem disablePadding>
<ListItemButton component={Link} to="/community" onClick={() => setDrawerOpen(false)}>
<ListItemText primary={t('nav.community')} />
</ListItemButton>
</ListItem>
)}
<ListItemButton onClick={() => setCatOpen(!catOpen)}>
<ListItemText primary={t('nav.categories')} />
{catOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
<Collapse in={catOpen} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{categories.map(cat => (
<ListItemButton
key={cat.slug}
component={Link}
to={`/category/${cat.slug}`}
onClick={() => setDrawerOpen(false)}
sx={{ pl: 4 }}
const hamburger = (
<IconButton
color="inherit"
onClick={() => setDrawerOpen((o) => !o)}
aria-label="toggle navigation"
sx={{ mx: 0.5 }}
>
<ListItemText primary={t(`categories.${cat.slug}`, { defaultValue: cat.name })} />
</ListItemButton>
))}
</List>
</Collapse>
<Divider sx={{ my: 1 }} />
{user ? (
<>
<ListItem disablePadding>
<ListItemButton component={Link} to={`/profile/${user.username}`} onClick={() => setDrawerOpen(false)}>
<ListItemText
primary={t('nav.profile')}
secondary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}>
<Avatar src={user.avatar || undefined} sx={{ width: 20, height: 20 }}>
{user.username.charAt(0).toUpperCase()}
</Avatar>
<Typography variant="caption" color="text.secondary">
{user.username}
</Typography>
</Box>
}
/>
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton component={Link} to="/dashboard" onClick={() => setDrawerOpen(false)}>
<ListItemText primary={t('nav.dashboard')} />
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton component={Link} to="/settings" onClick={() => setDrawerOpen(false)}>
<ListItemText primary={t('nav.settings')} />
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton component={Link} to="/support" onClick={() => setDrawerOpen(false)}>
<ListItemText primary={t('support.title')} />
</ListItemButton>
</ListItem>
{user.is_superuser && (
<ListItem disablePadding>
<ListItemButton onClick={toggleAdminMode}>
<ListItemText
primary={t('admin.adminMode')}
secondary={adminMode ? "ON" : "OFF"}
sx={{ color: adminMode ? 'primary.main' : 'inherit' }}
/>
</ListItemButton>
</ListItem>
)}
{user.is_superuser && (
<ListItem disablePadding>
<ListItemButton component={Link} to="/admin-panel" onClick={() => setDrawerOpen(false)}>
<ListItemText primary="Admin Panel" sx={{ color: 'primary.main', fontWeight: 'bold' }} />
</ListItemButton>
</ListItem>
)}
{following.length > 0 && (
<>
<Divider sx={{ my: 1 }} />
<ListSubheader sx={{ bgcolor: 'transparent', fontWeight: 'bold', lineHeight: '32px' }}>{t('nav.following')}</ListSubheader>
{following.map(f => (
<ListItem key={f.id} disablePadding>
<ListItemButton component={Link} to={`/profile/${f.username}`} onClick={() => setDrawerOpen(false)}>
<ListItemAvatar>
<Avatar src={f.avatar || undefined} sx={{ width: 32, height: 32 }}>
{!f.avatar && f.username.charAt(0).toUpperCase()}
</Avatar>
</ListItemAvatar>
<ListItemText primary={f.username} primaryTypographyProps={{ variant: 'body2' }} />
</ListItemButton>
</ListItem>
))}
</>
)}
<ListItem disablePadding>
<ListItemButton onClick={handleLogout}>
<ListItemText primary={t('nav.logout')} sx={{ color: 'error.main' }} />
</ListItemButton>
</ListItem>
</>
) : (
<ListItem disablePadding>
<ListItemButton component={Link} to="/login" onClick={() => setDrawerOpen(false)}>
<ListItemText primary={t('nav.login')} />
</ListItemButton>
</ListItem>
)}
</List>
</Box>
<MenuIcon />
</IconButton>
);
const notificationsMenu = (
@@ -499,47 +130,46 @@ const App: React.FC = () => {
sx={{ mt: 1 }}
PaperProps={{ sx: { width: 320, maxHeight: 400, borderRadius: 1 } }}
>
<Box sx={{ p: 1.5, display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>Notifications</Typography>
<Box
sx={{
p: 1.5,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
Notifications
</Typography>
{(user?.unread_notifications_count || 0) > 0 && (
<Button size="small" onClick={handleMarkAllRead}>Mark all read</Button>
<Button size="small" onClick={markAllRead}>
Mark all read
</Button>
)}
</Box>
{notifications.length === 0 ? (
<Box sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">No notifications yet</Typography>
<Typography variant="body2" color="text.secondary">
No notifications yet
</Typography>
</Box>
) : (
notifications.map(n => {
const handleClick = () => {
if (n.notification_type.startsWith('ticket')) {
handleNotificationClick(n);
navigate('/support');
} else if (n.notification_type === 'profile_comment') {
handleNotificationClick(n);
navigate(`/profile/${user?.username}`);
} else if (n.notification_type === 'follow') {
handleNotificationClick(n);
navigate(`/profile/${n.actor_username}`);
} else {
handleNotificationClick(n);
}
};
return (
notifications.map((n) => (
<MenuItem
key={n.id}
onClick={handleClick}
onClick={() => handleNotificationClick(n)}
sx={{
py: 1.5,
px: 2,
borderBottom: '1px solid',
borderColor: 'divider',
bgcolor: n.is_read ? 'transparent' : alpha(theme.palette.primary.main, 0.05),
bgcolor: n.is_read ? 'transparent' : alpha(muiTheme.palette.primary.main, 0.05),
whiteSpace: 'normal',
display: 'flex',
gap: 2,
alignItems: 'flex-start'
alignItems: 'flex-start',
}}
>
<Avatar src={n.actor_avatar || undefined} sx={{ width: 32, height: 32 }}>
@@ -547,88 +177,45 @@ const App: React.FC = () => {
</Avatar>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ lineHeight: 1.4 }}>
<strong>{n.actor_username}</strong> {
n.notification_type === 'reply' ? `replied to your review on ${n.flavor_name}` :
n.notification_type === 'mention' ? `mentioned you on ${n.flavor_name}` :
n.notification_type === 'follow' ? t('community.notifFollow') :
n.notification_type === 'profile_comment' ? `left a message on your guestbook` :
n.notification_type === 'ticket_new' ? t('community.notifTicketNew') :
user?.is_superuser ? t('community.notifTicketReplyAdmin') : t('community.notifTicketReply')
}
<strong>{n.actor_username}</strong>{' '}
{n.notification_type === 'reply'
? `replied to your review on ${n.flavor_name}`
: n.notification_type === 'mention'
? `mentioned you on ${n.flavor_name}`
: n.notification_type === 'follow'
? t('community.notifFollow')
: n.notification_type === 'profile_comment'
? `left a message on your guestbook`
: n.notification_type === 'ticket_new'
? t('community.notifTicketNew')
: user?.is_superuser
? t('community.notifTicketReplyAdmin')
: t('community.notifTicketReply')}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(n.created_at)}
</Typography>
</Box>
</MenuItem>
);
})
))
)}
</Menu>
</>
);
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', width: '100%', overflowX: 'hidden' }}>
<AppBar
position="sticky"
elevation={0}
<Box
sx={{
borderBottom: '1px solid',
borderColor: 'divider',
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.8),
backdropFilter: 'blur(12px)',
color: 'text.primary'
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
width: '100%',
overflowX: 'hidden',
}}
>
<Toolbar sx={{ minHeight: { xs: 56, sm: 64 }, px: { xs: 1, sm: 4, md: 6 } }}>
<Typography variant="h6" component="div" sx={{ fontWeight: 'bold', mr: { xs: 0, sm: 2 }, display: { xs: 'none', sm: 'block' } }}>
<Link to="/" style={{ color: 'inherit', textDecoration: 'none' }}>
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>Holy Flavors Archive</Box>
<Box component="span" sx={{ display: { xs: 'inline', sm: 'none' } }}>HFA</Box>
</Link>
</Typography>
<Box sx={{ display: isMobile ? 'none' : 'flex', ml: 2 }}>
{user && (
<Button
color="inherit"
component={Link}
to="/community"
sx={{ fontWeight: 'bold', textTransform: 'none', fontSize: '1rem', mr: 2 }}
>
{t('nav.community')}
</Button>
)}
<Button
color="inherit"
endIcon={<ArrowDropDownIcon />}
onClick={(e) => setCatAnchorEl(e.currentTarget)}
sx={{ fontWeight: 'bold', textTransform: 'none', fontSize: '1rem' }}
>
{t('nav.categories')}
</Button>
<Menu
anchorEl={catAnchorEl}
open={Boolean(catAnchorEl)}
onClose={() => setCatAnchorEl(null)}
elevation={3}
sx={{ mt: 1 }}
>
{categories.map(cat => (
<MenuItem
key={cat.slug}
component={Link}
to={`/category/${cat.slug}`}
onClick={() => setCatAnchorEl(null)}
>
{t(`categories.${cat.slug}`, { defaultValue: cat.name })}
</MenuItem>
))}
</Menu>
</Box>
{isMobile && (
<GlassAppBar>
{mobileAnchor === 'left' && hamburger}
<GlobalSearch />
@@ -637,90 +224,142 @@ const App: React.FC = () => {
<>
{user && notificationsMenu}
{user ? (
<Box sx={{ display: isMobile ? 'none' : 'flex', ml: 1 }}>
<IconButton color="inherit" onClick={(e) => setAnchorEl(e.currentTarget)}>
<Avatar src={user.avatar || undefined} sx={{ width: 36, height: 36, border: '2px solid', borderColor: 'primary.main' }}>
{user.username.charAt(0).toUpperCase()}
</Avatar>
</IconButton>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
elevation={3}
sx={{ mt: 1 }}
{!user && (
<Button
variant="contained"
component={Link}
to="/login"
sx={{ borderRadius: 2, ml: 1 }}
>
<MenuItem component={Link} to={`/profile/${user.username}`} onClick={() => setAnchorEl(null)}>{t('nav.profile')}</MenuItem>
<MenuItem component={Link} to="/dashboard" onClick={() => setAnchorEl(null)}>{t('nav.dashboard')}</MenuItem>
<MenuItem component={Link} to="/settings" onClick={() => setAnchorEl(null)}>{t('nav.settings')}</MenuItem>
<MenuItem component={Link} to="/support" onClick={() => setAnchorEl(null)}>{t('support.title')}</MenuItem>
{user.is_superuser && (
<MenuItem onClick={toggleAdminMode} sx={{ color: adminMode ? 'primary.main' : 'inherit' }}>
{t('admin.adminMode')}: {adminMode ? "ON" : "OFF"}
</MenuItem>
)}
{user.is_superuser && (
<MenuItem component={Link} to="/admin-panel" onClick={() => setAnchorEl(null)} sx={{ fontWeight: 'bold', color: 'primary.main' }}>Admin Panel</MenuItem>
)} <Divider />
<MenuItem onClick={handleLogout} sx={{ color: 'error.main' }}>{t('nav.logout')}</MenuItem> </Menu>
</Box>
) : (
<Box sx={{ display: isMobile ? 'none' : 'block' }}>
<Button variant="contained" component={Link} to="/login" sx={{ borderRadius: 2 }}>Login</Button>
</Box>
{t('nav.login')}
</Button>
)}
</>
)}
{isMobile && (
<IconButton
color="inherit"
onClick={() => setDrawerOpen(true)}
sx={{ ml: 1 }}
>
<MenuIcon />
</IconButton>
)}
{mobileAnchor === 'right' && hamburger}
</Box>
</Toolbar>
</AppBar>
</GlassAppBar>
)}
<Drawer
anchor="right"
{isMobile && (
<SwipeableDrawer
anchor={mobileAnchor}
open={drawerOpen}
onOpen={() => setDrawerOpen(true)}
onClose={() => setDrawerOpen(false)}
disableBackdropTransition
disableDiscovery
>
{drawer}
</Drawer>
<NavSidebar categories={categories} onNavigate={() => setDrawerOpen(false)} />
</SwipeableDrawer>
)}
<Box sx={{ display: 'flex', flexGrow: 1, minHeight: 0 }}>
{!isMobile && (
<Box
component="aside"
sx={{
width: NAV_SIDEBAR_WIDTH,
flexShrink: 0,
position: 'sticky',
top: 0,
alignSelf: 'flex-start',
height: '100vh',
borderRight: '1px solid',
borderColor: (theme) => theme.tokens.glass.border,
bgcolor: (theme) =>
alpha(
theme.palette.background.default,
theme.palette.mode === 'light' ? 0.65 : 0.45,
),
backdropFilter: (theme) => theme.tokens.glass.blurStrong,
WebkitBackdropFilter: (theme) => theme.tokens.glass.blurStrong,
zIndex: 1,
}}
>
<NavSidebar categories={categories} showSearch showNotifications />
</Box>
)}
<Box
component="main"
sx={{
flexGrow: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
}}
>
<Box sx={{ flexGrow: 1, width: '100%' }}>
<Suspense fallback={<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>}>
<Suspense
fallback={
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
<CircularProgress />
</Box>
}
>
<Routes>
<Route path="/" element={<MainPage adminMode={adminMode} />} />
<Route path="/" element={<MainPage />} />
<Route path="/category/:slug" element={<CategoryFlavors />} />
<Route path="/flavor/:id" element={<FlavorDetail adminMode={adminMode} />} />
<Route path="/profile/:username" element={<PublicProfile adminMode={adminMode} />} />
<Route path="/community" element={<CommunityFeed adminMode={adminMode} />} />
<Route path="/flavor/:id" element={<FlavorDetail />} />
<Route path="/profile/:username" element={<PublicProfile />} />
<Route path="/community" element={<CommunityFeed />} />
<Route path="/about" element={<About />} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings themeName={themeName} onThemeChange={handleThemeChange} />} />
<Route
path="/settings"
element={<Settings themeName={themeName} onThemeChange={onThemeChange} />}
/>
<Route path="/support" element={<Support />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/admin-panel" element={<AdminDashboard />} />
<Route path="/admin-panel/user/:id" element={<AdminUserDetail />} />
<Route path="/admin-panel/rating/:id" element={<AdminRatingDetail />} />
<Route path="/admin-panel/reply/:id" element={<AdminReplyDetail />} />
<Route path="/forbidden" element={<Forbidden />} />
<Route
path="/admin-panel"
element={
<RequireSuperuser>
<AdminLayout />
</RequireSuperuser>
}
>
<Route index element={<Navigate to="overview" replace />} />
<Route path="overview" element={<AdminOverview />} />
<Route path="users" element={<AdminUsers />} />
<Route path="users/:id" element={<AdminUserDetail />} />
<Route path="user/:id" element={<AdminUserDetail />} />
<Route path="banners" element={<AdminBanners />} />
<Route path="jobs" element={<AdminJobs />} />
<Route path="tickets" element={<AdminTickets />} />
<Route path="config" element={<AdminConfig />} />
<Route path="ratings/:id" element={<AdminRatingDetail />} />
<Route path="rating/:id" element={<AdminRatingDetail />} />
<Route path="replies/:id" element={<AdminReplyDetail />} />
<Route path="reply/:id" element={<AdminReplyDetail />} />
</Route>
<Route path="/login" element={<Login />} />
<Route path="*" element={<Box sx={{ p: 4, textAlign: 'center' }}><Typography variant="h5">404 - Page Not Found</Typography><Button component={Link} to="/" sx={{ mt: 2 }}>Back to Home</Button></Box>} />
<Route
path="*"
element={
<Box sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="h5">404 - Page Not Found</Typography>
<Button component={Link} to="/" sx={{ mt: 2 }}>
Back to Home
</Button>
</Box>
}
/>
</Routes>
</Suspense>
</Box>
<Footer />
</Box>
<CookieBanner onThemeChange={handleThemeChange} currentTheme={themeName} />
</ThemeProvider>
</Box>
<CookieBanner onThemeChange={(name) => onThemeChange(name)} currentTheme={themeName} />
</Box>
);
};
-42
View File
@@ -1,42 +0,0 @@
import axios from 'axios';
function getCookie(name: string): string | null {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api/',
});
api.interceptors.request.use((config) => {
// Set DRF Auth Token if available from localStorage
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Token ${token}`;
}
// Set CSRF token for state-changing methods
const csrfToken = getCookie('csrftoken');
const method = config.method?.toUpperCase();
if (csrfToken && method && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
if (config.headers) {
config.headers['X-CSRFToken'] = csrfToken;
}
}
return config;
});
export default api;
+16
View File
@@ -0,0 +1,16 @@
/**
* Central query-key factory. Keys live here so mutations can invalidate the
* right slices without grepping. Convention: scope first, then identifying
* params (URL-stable, not the full args object).
*/
export const queryKeys = {
dashboard: () => ['dashboard'] as const,
publicProfile: (username: string) => ['publicProfile', username] as const,
publicProfileAll: () => ['publicProfile'] as const,
flavors: (params: Record<string, unknown>) => ['flavors', params] as const,
flavorsAll: () => ['flavors'] as const,
flavorDetail: (id: number | string) => ['flavorDetail', String(id)] as const,
activeBanner: (username: string) => ['activeBanner', username] as const,
notifications: () => ['notifications'] as const,
communityFeed: (params: Record<string, unknown>) => ['communityFeed', params] as const,
};
@@ -0,0 +1,91 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../lib/api';
const invalidateAdmin = (qc: ReturnType<typeof useQueryClient>) => {
qc.invalidateQueries({ queryKey: ['adminStats'] });
qc.invalidateQueries({ queryKey: ['adminUsers'] });
qc.invalidateQueries({ queryKey: ['adminJobs'] });
qc.invalidateQueries({ queryKey: ['adminConfig'] });
};
export const useUpdateAdminConfig = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ key, value }: { key: string; value: unknown }) =>
api.patch('admin-custom/config/', { [key]: value }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['adminConfig'] });
},
});
};
export const useTriggerJob = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.post(`admin-custom/${id}/trigger_job/`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['adminJobs'] });
},
});
};
export const useUpdateJobSchedule = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) =>
api.patch(`admin-custom/${id}/update_job_schedule/`, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['adminJobs'] });
},
});
};
export const useActivateBanner = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.post(`banners/${id}/activate/`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['banners'] });
qc.invalidateQueries({ queryKey: ['activeBanner'] });
},
});
};
export const useToggleBannerEnabled = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.post(`banners/${id}/toggle_enabled/`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['banners'] });
},
});
};
export const useSendTestEmail = () =>
useMutation({
mutationFn: () => api.post('admin-custom/send_test_email/'),
});
export const useToggleUserActive = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, isActive }: { id: string | number; isActive: boolean }) =>
api.patch(`admin-custom/${id}/user_detail/`, { is_active: !isActive }),
onSuccess: (_, vars) => {
qc.invalidateQueries({ queryKey: ['adminUserDetail', String(vars.id)] });
qc.invalidateQueries({ queryKey: ['adminUsers'] });
},
});
};
export const useDeleteUser = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string | number) => api.delete(`admin-custom/${id}/user_detail/`),
onSuccess: (_, id) => {
qc.invalidateQueries({ queryKey: ['adminUserDetail', String(id)] });
qc.invalidateQueries({ queryKey: ['adminUsers'] });
invalidateAdmin(qc);
},
});
};
@@ -0,0 +1,25 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../lib/api';
import { queryKeys } from '../keys';
export interface UpdateFlavorInput {
id: string | number;
data: Record<string, unknown> | FormData;
isFormData?: boolean;
}
export const useUpdateFlavor = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data, isFormData }: UpdateFlavorInput) =>
api.patch(
`flavors/${id}/`,
data,
isFormData ? { headers: { 'Content-Type': 'multipart/form-data' } } : undefined,
),
onSuccess: (_, vars) => {
qc.invalidateQueries({ queryKey: queryKeys.flavorDetail(vars.id) });
qc.invalidateQueries({ queryKey: queryKeys.flavorsAll() });
},
});
};
@@ -0,0 +1,28 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../lib/api';
import { queryKeys } from '../keys';
import type { Notification } from '../queries/useNotifications';
export const useMarkAllNotificationsRead = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.post('notifications/mark_all_read/'),
onSuccess: () => {
qc.setQueryData<Notification[]>(queryKeys.notifications(), (old) =>
old?.map((n) => ({ ...n, is_read: true })),
);
},
});
};
export const useMarkNotificationRead = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.post(`notifications/${id}/mark_read/`),
onSuccess: (_, id) => {
qc.setQueryData<Notification[]>(queryKeys.notifications(), (old) =>
old?.map((n) => (n.id === id ? { ...n, is_read: true } : n)),
);
},
});
};
@@ -0,0 +1,70 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../lib/api';
import { queryKeys } from '../keys';
const invalidateRatingViews = (qc: ReturnType<typeof useQueryClient>) => {
qc.invalidateQueries({ queryKey: queryKeys.dashboard() });
qc.invalidateQueries({ queryKey: queryKeys.publicProfileAll() });
qc.invalidateQueries({ queryKey: queryKeys.flavorsAll() });
qc.invalidateQueries({ queryKey: ['flavorDetail'] });
qc.invalidateQueries({ queryKey: ['communityFeed'] });
};
export interface UpdateRatingInput {
id: number;
score: number;
comment: string | null;
}
export const useUpdateRating = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, score, comment }: UpdateRatingInput) =>
api.patch(`ratings/${id}/`, { score, comment }),
onSuccess: () => invalidateRatingViews(qc),
});
};
export const useDeleteRating = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.delete(`ratings/${id}/`),
onSuccess: () => invalidateRatingViews(qc),
});
};
export interface CreateRatingInput {
flavor: number;
score: number;
comment?: string | null;
}
export const useCreateRating = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateRatingInput) => api.post('ratings/', input),
onSuccess: () => invalidateRatingViews(qc),
});
};
export interface ReplyInput {
ratingId: number;
text: string;
}
export const useCreateReply = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ ratingId, text }: ReplyInput) =>
api.post(`ratings/${ratingId}/reply/`, { text }),
onSuccess: () => invalidateRatingViews(qc),
});
};
export const useDeleteReply = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (replyId: number) => api.delete(`replies/${replyId}/`),
onSuccess: () => invalidateRatingViews(qc),
});
};
@@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../lib/api';
import { queryKeys } from '../keys';
const invalidateRatingViews = (qc: ReturnType<typeof useQueryClient>) => {
qc.invalidateQueries({ queryKey: queryKeys.dashboard() });
qc.invalidateQueries({ queryKey: queryKeys.publicProfileAll() });
qc.invalidateQueries({ queryKey: queryKeys.flavorsAll() });
qc.invalidateQueries({ queryKey: ['flavorDetail'] });
qc.invalidateQueries({ queryKey: ['communityFeed'] });
};
export const useUpdateReply = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ replyId, text }: { replyId: number; text: string }) =>
api.patch(`replies/${replyId}/`, { text }),
onSuccess: () => invalidateRatingViews(qc),
});
};
@@ -0,0 +1,62 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../lib/api';
const invalidateMe = (qc: ReturnType<typeof useQueryClient>) => {
qc.invalidateQueries({ queryKey: ['me'] });
qc.invalidateQueries({ queryKey: ['currentUserLite'] });
};
export const useUpdateAvatar = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (file: File) => {
const formData = new FormData();
formData.append('avatar', file);
return api.post<{ avatar: string }>('users/update_avatar/', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
onSuccess: () => invalidateMe(qc),
});
};
export const useUpdatePreferences = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (prefs: Record<string, unknown>) => api.patch('users/update_preferences/', prefs),
onSuccess: () => invalidateMe(qc),
});
};
export const useUpdateProfile = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: { username: string; email: string }) =>
api.patch<{ message?: string }>('users/update_profile/', input),
onSuccess: () => invalidateMe(qc),
});
};
export const useConfirmEmail = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (code: string) => api.post<{ email: string }>('users/confirm_email/', { code }),
onSuccess: () => invalidateMe(qc),
});
};
export const useChangePassword = () =>
useMutation({
mutationFn: (input: { old_password: string; new_password: string }) =>
api.post('users/change_password/', input),
});
export const useRequestAccountDeletion = () =>
useMutation({
mutationFn: () => api.post('users/request_account_deletion/'),
});
export const useConfirmAccountDeletion = () =>
useMutation({
mutationFn: (code: string) => api.post('users/confirm_account_deletion/', { code }),
});
@@ -0,0 +1,36 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../lib/api';
import { queryKeys } from '../keys';
export const useFollowToggle = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, currentlyFollowing }: { userId: number; currentlyFollowing: boolean }) =>
api.post(`users/${userId}/${currentlyFollowing ? 'unfollow' : 'follow'}/`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: queryKeys.publicProfileAll() });
},
});
};
export const useAddProfileComment = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, text }: { userId: number; text: string }) =>
api.post(`users/${userId}/add_comment/`, { text }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: queryKeys.publicProfileAll() });
},
});
};
export const useDeleteProfileComment = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, commentId }: { userId: number; commentId: number }) =>
api.delete(`users/${userId}/delete_comment/${commentId}/`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: queryKeys.publicProfileAll() });
},
});
};
@@ -0,0 +1,40 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../lib/api';
const invalidateTickets = (qc: ReturnType<typeof useQueryClient>) => {
qc.invalidateQueries({ queryKey: ['tickets'] });
};
export const useCreateTicket = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: { subject: string; description: string }) => api.post('tickets/', input),
onSuccess: () => invalidateTickets(qc),
});
};
export const useAddTicketMessage = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ ticketId, text }: { ticketId: number; text: string }) =>
api.post(`tickets/${ticketId}/add_message/`, { text }),
onSuccess: () => invalidateTickets(qc),
});
};
export const useUpdateTicketStatus = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ ticketId, status }: { ticketId: number; status: string }) =>
api.post(`tickets/${ticketId}/update_status/`, { status }),
onSuccess: () => invalidateTickets(qc),
});
};
export const useDeleteTicket = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (ticketId: number) => api.delete(`tickets/${ticketId}/`),
onSuccess: () => invalidateTickets(qc),
});
};
@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import api from '../../lib/api';
import { queryKeys } from '../keys';
import type { BannerConfig } from '../types';
export const useActiveBanner = (username: string) =>
useQuery({
queryKey: queryKeys.activeBanner(username),
queryFn: async () => {
const res = await api.get<BannerConfig>('banners/active/', { params: { username } });
return res.data;
},
enabled: !!username,
staleTime: 5 * 60_000,
});
+106
View File
@@ -0,0 +1,106 @@
import { useQuery } from '@tanstack/react-query';
import api from '../../lib/api';
export interface AdminRating {
id: number;
user: string;
score: number;
comment: string | null;
flavor_name: string;
}
export interface AdminReply {
id: number;
user: string;
text: string;
}
export const useAdminRating = (id: string | undefined) =>
useQuery({
queryKey: ['adminRating', id],
queryFn: async () => {
const res = await api.get<AdminRating>(`ratings/${id}/`);
return res.data;
},
enabled: !!id,
});
export const useAdminReply = (id: string | undefined) =>
useQuery({
queryKey: ['adminReply', id],
queryFn: async () => {
const res = await api.get<AdminReply>(`replies/${id}/`);
return res.data;
},
enabled: !!id,
});
export interface AdminStats {
[key: string]: unknown;
}
export interface AdminUserSummary {
id: number;
username: string;
email?: string;
is_active?: boolean;
is_superuser?: boolean;
date_joined?: string;
rating_count?: number;
}
export interface AdminJob {
id: number;
name: string;
status?: string;
schedule?: unknown;
next_run?: string | null;
}
export interface AdminConfig {
[key: string]: unknown;
}
export const useAdminStats = () =>
useQuery({
queryKey: ['adminStats'],
queryFn: async () => (await api.get<AdminStats>('admin-custom/stats/')).data,
});
export const useAdminUsers = () =>
useQuery({
queryKey: ['adminUsers'],
queryFn: async () => (await api.get<AdminUserSummary[]>('admin-custom/users/')).data,
});
export const useAdminJobs = () =>
useQuery({
queryKey: ['adminJobs'],
queryFn: async () => (await api.get<AdminJob[]>('admin-custom/jobs/')).data,
refetchInterval: 30_000,
});
export const useAdminConfig = () =>
useQuery({
queryKey: ['adminConfig'],
queryFn: async () => (await api.get<AdminConfig>('admin-custom/config/')).data,
});
export interface AdminUserDetailData {
id: number;
username: string;
email?: string;
is_active: boolean;
is_superuser?: boolean;
date_joined?: string;
ratings?: unknown[];
[key: string]: unknown;
}
export const useAdminUserDetail = (id: string | undefined) =>
useQuery({
queryKey: ['adminUserDetail', id],
queryFn: async () =>
(await api.get<AdminUserDetailData>(`admin-custom/${id}/user_detail/`)).data,
enabled: !!id,
});
+21
View File
@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import api from '../../lib/api';
export interface BannerListItem {
id: number;
name: string;
slug: string;
description?: string;
is_active?: boolean;
is_enabled?: boolean;
}
export const useBannersList = () =>
useQuery({
queryKey: ['banners'],
queryFn: async () => {
const res = await api.get<BannerListItem[] | { results: BannerListItem[] }>('banners/');
return Array.isArray(res.data) ? res.data : (res.data.results ?? []);
},
staleTime: 5 * 60_000,
});
@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import api from '../../lib/api';
import { queryKeys } from '../keys';
import type { FlavorListItem } from './useFlavorsList';
export const useCategoryFlavors = (slug: string | undefined) =>
useQuery({
queryKey: queryKeys.flavors({ category: slug ?? '' }),
queryFn: async () => {
const res = await api.get<FlavorListItem[] | { results: FlavorListItem[] }>(
`flavors/?category__slug=${slug}`,
);
const data = res.data;
return Array.isArray(data) ? data : (data.results ?? []);
},
enabled: !!slug,
});
@@ -0,0 +1,81 @@
import { useQuery } from '@tanstack/react-query';
import api from '../../lib/api';
import { queryKeys } from '../keys';
export interface FeedReply {
id: number;
user: string;
text: string;
created_at: string;
}
export interface FeedRating {
id: number;
user: string;
user_id: number;
user_avatar: string | null;
flavor: number;
flavor_name: string;
flavor_image: string | null;
category_name: string;
category_slug: string;
score: number;
comment: string;
created_at: string;
replies: FeedReply[];
}
export interface FollowedUser {
id: number;
username: string;
avatar: string | null;
}
export interface FollowedTopFlavor {
id: number;
name: string;
image_url: string | null;
average_rating: number;
}
export interface CommunityFeedResult {
ratings: FeedRating[];
totalPages: number;
}
export const useCommunityFeed = (page: number) =>
useQuery({
queryKey: queryKeys.communityFeed({ page }),
queryFn: async (): Promise<CommunityFeedResult> => {
const res = await api.get<{ results?: FeedRating[]; count?: number } | FeedRating[]>(
`ratings/feed/?page=${page}`,
);
if (Array.isArray(res.data)) {
return { ratings: res.data, totalPages: 1 };
}
const ratings = res.data.results ?? [];
const totalPages = Math.ceil((res.data.count ?? 0) / 10) || 1;
return { ratings, totalPages };
},
placeholderData: (prev) => prev,
});
export const useFollowingList = () =>
useQuery({
queryKey: ['followingList'],
queryFn: async () => {
const res = await api.get<FollowedUser[] | { results: FollowedUser[] }>(
'users/following_list/',
);
return Array.isArray(res.data) ? res.data : (res.data.results ?? []);
},
});
export const useFollowedTopFlavors = () =>
useQuery({
queryKey: ['followedTopFlavors'],
queryFn: async () => {
const res = await api.get<FollowedTopFlavor[]>('flavors/followed_top/');
return Array.isArray(res.data) ? res.data.slice(0, 5) : [];
},
});
@@ -0,0 +1,29 @@
import { useQuery } from '@tanstack/react-query';
import api from '../../lib/api';
import { useAuth } from '../../hooks/useAuth';
export interface CurrentUserLite {
username: string;
is_superuser: boolean;
}
/**
* Reads /users/me/ for cases where we need a quick "is this me / am I admin"
* check outside of AuthContext. Disabled when not authenticated.
*/
export const useCurrentUserLite = () => {
const { user } = useAuth();
return useQuery({
queryKey: ['currentUserLite'],
queryFn: async () => {
const res = await api.get<CurrentUserLite>('users/me/', {
// Stale access tokens are common; never bounce a public page reader
// to /login because of a background "am I logged in?" check.
...({ skipAuthRedirect: true } as object),
});
return res.data;
},
enabled: !!user,
staleTime: 60_000,
});
};
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff. Show more