mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
Merge pull request 'feature/refactor' (#1) from feature/refactor into main
Reviewed-on: nvrl/Holy-Flavor-Rater#1
This commit is contained in:
commit
86e5b18bd3
204 files changed
+24451
-7858
No files matched your search
+23
-1
@@ -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
@@ -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
|
||||
@@ -21,6 +21,7 @@ backend/staticfiles/
|
||||
backend/media/
|
||||
backend/django_cache/
|
||||
backend/backups/
|
||||
backend/.seeded
|
||||
|
||||
# React/Vite build
|
||||
dist/
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -2,4 +2,4 @@ from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
name = 'api'
|
||||
name = "api"
|
||||
@@ -1,6 +1,8 @@
|
||||
import ssl
|
||||
from django.core.mail.backends.smtp import EmailBackend
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail.backends.smtp import EmailBackend
|
||||
|
||||
|
||||
class InsecureEmailBackend(EmailBackend):
|
||||
"""
|
||||
@@ -8,9 +10,10 @@ class InsecureEmailBackend(EmailBackend):
|
||||
Useful for local development or internal SMTP servers with self-signed certificates.
|
||||
Set EMAIL_SKIP_CERT_VERIFICATION = True in settings to use.
|
||||
"""
|
||||
|
||||
@property
|
||||
def ssl_context(self):
|
||||
if getattr(settings, 'EMAIL_SKIP_CERT_VERIFICATION', False):
|
||||
if getattr(settings, "EMAIL_SKIP_CERT_VERIFICATION", False):
|
||||
# The most robust way to bypass verification for self-signed certs
|
||||
context = ssl._create_unverified_context()
|
||||
return context
|
||||
|
||||
@@ -1,35 +1,40 @@
|
||||
import sqlite3
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Creates a safe, consistent backup of the SQLite database and manages rotation.'
|
||||
help = "Creates a safe, consistent backup of the SQLite database and manages rotation."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--output', type=str, help='Specific output path for the backup file')
|
||||
parser.add_argument('--retention', type=int, default=7, help='Number of days to keep old backups')
|
||||
parser.add_argument('--full', action='store_true', help='Create a full archive including media')
|
||||
parser.add_argument("--output", type=str, help="Specific output path for the backup file")
|
||||
parser.add_argument(
|
||||
"--retention", type=int, default=7, help="Number of days to keep old backups"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--full", action="store_true", help="Create a full archive including media"
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
db_conf = settings.DATABASES['default']
|
||||
if db_conf['ENGINE'] != 'django.db.backends.sqlite3':
|
||||
db_conf = settings.DATABASES["default"]
|
||||
if db_conf["ENGINE"] != "django.db.backends.sqlite3":
|
||||
self.stdout.write(self.style.ERROR("This command only supports SQLite."))
|
||||
return
|
||||
|
||||
db_path = str(db_conf['NAME'])
|
||||
backup_dir = os.path.join(settings.BASE_DIR, 'backups')
|
||||
db_path = str(db_conf["NAME"])
|
||||
backup_dir = os.path.join(settings.BASE_DIR, "backups")
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
# 1. Database Backup
|
||||
if options['output']:
|
||||
db_backup_path = options['output']
|
||||
if options["output"]:
|
||||
db_backup_path = options["output"]
|
||||
else:
|
||||
db_backup_path = os.path.join(backup_dir, f'db_backup_{timestamp}.sqlite3')
|
||||
db_backup_path = os.path.join(backup_dir, f"db_backup_{timestamp}.sqlite3")
|
||||
|
||||
self.stdout.write(f"Cloning database {db_path}...")
|
||||
|
||||
@@ -46,8 +51,9 @@ class Command(BaseCommand):
|
||||
return
|
||||
|
||||
# 2. Full Archive (Optional)
|
||||
if options['full']:
|
||||
if options["full"]:
|
||||
import tarfile
|
||||
|
||||
archive_name = f"full_backup_{timestamp}.tar.gz"
|
||||
archive_path = os.path.join(backup_dir, archive_name)
|
||||
self.stdout.write(f"Creating full archive at {archive_path}...")
|
||||
@@ -62,15 +68,15 @@ class Command(BaseCommand):
|
||||
tar.add(media_dir, arcname="media")
|
||||
|
||||
# Remove the raw sqlite backup after bundling it into the archive
|
||||
if not options['output']:
|
||||
if not options["output"]:
|
||||
os.remove(db_backup_path)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Full archive created successfully."))
|
||||
self.stdout.write(self.style.SUCCESS("Full archive created successfully."))
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f"Archive creation failed: {e}"))
|
||||
|
||||
# 3. Cleanup Old Backups
|
||||
retention_days = options['retention']
|
||||
retention_days = options["retention"]
|
||||
cutoff = datetime.now() - timedelta(days=retention_days)
|
||||
|
||||
self.stdout.write(f"Cleaning up backups older than {retention_days} days...")
|
||||
@@ -81,6 +87,10 @@ class Command(BaseCommand):
|
||||
if file_time < cutoff:
|
||||
try:
|
||||
os.remove(file_path)
|
||||
self.stdout.write(self.style.SUCCESS(f" -> Deleted old backup: {filename}"))
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" -> Deleted old backup: {filename}")
|
||||
)
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.WARNING(f" -> Failed to delete {filename}: {e}"))
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f" -> Failed to delete {filename}: {e}")
|
||||
)
|
||||
@@ -1,33 +1,38 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from api.models import Flavor, Rating
|
||||
from django.db.models import Count
|
||||
|
||||
from api.models import Flavor, Rating
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Identifies and merges duplicate flavors (same name and category)'
|
||||
help = "Identifies and merges duplicate flavors (same name and category)"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Find duplicates based on name and category
|
||||
duplicates = Flavor.objects.values('name', 'category').annotate(
|
||||
name_count=Count('id')
|
||||
).filter(name_count__gt=1)
|
||||
duplicates = (
|
||||
Flavor.objects.values("name", "category")
|
||||
.annotate(name_count=Count("id"))
|
||||
.filter(name_count__gt=1)
|
||||
)
|
||||
|
||||
self.stdout.write(f"Found {duplicates.count()} sets of duplicates.")
|
||||
|
||||
for dup in duplicates:
|
||||
name = dup['name']
|
||||
category_id = dup['category']
|
||||
name = dup["name"]
|
||||
category_id = dup["category"]
|
||||
|
||||
# Get all flavors in this set, ordered by having an external_id first, then by date
|
||||
flavor_set = Flavor.objects.filter(
|
||||
name=name,
|
||||
category_id=category_id
|
||||
).order_by('-external_id', 'created_at')
|
||||
flavor_set = Flavor.objects.filter(name=name, category_id=category_id).order_by(
|
||||
"-external_id", "created_at"
|
||||
)
|
||||
|
||||
# Keep the first one, merge others into it
|
||||
keep = flavor_set[0]
|
||||
others = flavor_set[1:]
|
||||
|
||||
self.stdout.write(f"Merging {len(others)} duplicates into '{keep.name}' (ID: {keep.id}, Ext: {keep.external_id})")
|
||||
self.stdout.write(
|
||||
f"Merging {len(others)} duplicates into '{keep.name}' (ID: {keep.id}, Ext: {keep.external_id})"
|
||||
)
|
||||
|
||||
for other in others:
|
||||
# Re-assign ratings
|
||||
|
||||
@@ -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")
|
||||
@@ -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.")
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
)
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -1,266 +0,0 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
|
||||
class User(AbstractUser):
|
||||
THEME_CHOICES = [
|
||||
('holy_light', 'Holy Light'),
|
||||
('holy_dark', 'Holy Dark'),
|
||||
('latte', 'Catppuccin Latte'),
|
||||
('frappe', 'Catppuccin Frappé'),
|
||||
('macchiato', 'Catppuccin Macchiato'),
|
||||
('mocha', 'Catppuccin Mocha'),
|
||||
('pink_pastel', 'Pink Pastel'),
|
||||
('mint_pastel', 'Mint Pastel'),
|
||||
('lavender_pastel', 'Lavender Pastel'),
|
||||
('dracula', 'Dracula'),
|
||||
('nord', 'Nordic Frost'),
|
||||
('gruvbox', 'Gruvbox Retro'),
|
||||
('oceanic', 'Oceanic Deep'),
|
||||
('t0p_sai', 'SAI (T0P)'),
|
||||
('t0p_trench', 'Trench (T0P)'),
|
||||
('t0p_blurryface', 'Blurryface (T0P)'),
|
||||
('t0p_clancy', 'Clancy (T0P)'),
|
||||
]
|
||||
LANGUAGE_CHOICES = [
|
||||
('en', 'English'),
|
||||
('de', 'Deutsch'),
|
||||
]
|
||||
theme = models.CharField(max_length=20, choices=THEME_CHOICES, default='holy_light')
|
||||
language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default='en')
|
||||
avatar = models.ImageField(upload_to='avatars/', blank=True, null=True)
|
||||
pending_email = models.EmailField(max_length=254, blank=True, null=True)
|
||||
email_confirmation_code = models.CharField(max_length=6, blank=True, null=True)
|
||||
following = models.ManyToManyField('self', symmetrical=False, related_name='followers', blank=True)
|
||||
selected_banner = models.ForeignKey('Banner', on_delete=models.SET_NULL, null=True, blank=True, related_name='users')
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
if self.avatar:
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(self.avatar.path)
|
||||
if img.height > 256 or img.width > 256 or img.format != 'JPEG':
|
||||
output_size = (256, 256)
|
||||
img.thumbnail(output_size)
|
||||
if img.mode in ("RGBA", "P"):
|
||||
img = img.convert("RGB")
|
||||
img.save(self.avatar.path, 'JPEG', quality=90)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
|
||||
class Category(models.Model):
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Categories"
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Flavor(models.Model):
|
||||
category = models.ForeignKey(Category, related_name='flavors', on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=100) # Removed unique=True here
|
||||
description = models.TextField(blank=True)
|
||||
image_url = models.URLField(max_length=1000, blank=True, null=True)
|
||||
image = models.ImageField(upload_to='flavors/', blank=True, null=True)
|
||||
shop_url = models.URLField(max_length=500, blank=True, null=True)
|
||||
is_available = models.BooleanField(default=True)
|
||||
is_legacy = models.BooleanField(default=False)
|
||||
external_id = models.BigIntegerField(unique=True, null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
# This allows "Erdbeere" in Iced Tea and "Erdbeere" in Milkshake
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['name', 'category'], name='unique_flavor_name_per_category')
|
||||
]
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.category.name})"
|
||||
|
||||
@property
|
||||
def get_average_rating(self):
|
||||
ratings = self.ratings.all()
|
||||
if ratings.exists():
|
||||
return sum(r.score for r in ratings) / ratings.count()
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def cached_image_url(self):
|
||||
if self.image:
|
||||
return self.image.url
|
||||
return self.image_url
|
||||
|
||||
class Rating(models.Model):
|
||||
user = models.ForeignKey(User, related_name='ratings', on_delete=models.CASCADE)
|
||||
flavor = models.ForeignKey(Flavor, related_name='ratings', on_delete=models.CASCADE)
|
||||
score = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(10)])
|
||||
comment = models.TextField(blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('user', 'flavor')
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} - {self.flavor.name}: {self.score}"
|
||||
|
||||
class Reply(models.Model):
|
||||
user = models.ForeignKey(User, related_name='replies', on_delete=models.CASCADE)
|
||||
rating = models.ForeignKey(Rating, related_name='replies', on_delete=models.CASCADE)
|
||||
text = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"Reply by {self.user.username} on {self.rating.id}"
|
||||
|
||||
class Notification(models.Model):
|
||||
TYPE_CHOICES = [
|
||||
('reply', 'Reply'),
|
||||
('mention', 'Mention'),
|
||||
('follow', 'Follow'),
|
||||
('ticket_new', 'New Ticket'),
|
||||
('ticket_reply', 'Ticket Reply'),
|
||||
('profile_comment', 'Profile Comment'),
|
||||
]
|
||||
recipient = models.ForeignKey(User, related_name='notifications', on_delete=models.CASCADE)
|
||||
actor = models.ForeignKey(User, related_name='actions', on_delete=models.CASCADE)
|
||||
notification_type = models.CharField(max_length=20, choices=TYPE_CHOICES)
|
||||
rating = models.ForeignKey(Rating, on_delete=models.CASCADE, null=True, blank=True)
|
||||
reply = models.ForeignKey(Reply, on_delete=models.CASCADE, null=True, blank=True)
|
||||
ticket = models.ForeignKey('Ticket', on_delete=models.CASCADE, null=True, blank=True)
|
||||
profile_comment = models.ForeignKey('ProfileComment', on_delete=models.CASCADE, null=True, blank=True)
|
||||
is_read = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.notification_type} for {self.recipient.username}"
|
||||
|
||||
class UserIP(models.Model):
|
||||
user = models.ForeignKey(User, related_name='ips', on_delete=models.CASCADE)
|
||||
ip_address = models.GenericIPAddressField()
|
||||
last_login = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('user', 'ip_address')
|
||||
ordering = ['-last_login']
|
||||
|
||||
class Ticket(models.Model):
|
||||
STATUS_CHOICES = [
|
||||
('open', 'Open'),
|
||||
('in_progress', 'In Progress'),
|
||||
('resolved', 'Resolved'),
|
||||
('closed', 'Closed'),
|
||||
]
|
||||
user = models.ForeignKey(User, related_name='tickets', on_delete=models.CASCADE)
|
||||
subject = models.CharField(max_length=200)
|
||||
description = models.TextField()
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='open')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-updated_at']
|
||||
|
||||
class TicketMessage(models.Model):
|
||||
ticket = models.ForeignKey(Ticket, related_name='messages', on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
text = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['created_at']
|
||||
|
||||
class ProfileComment(models.Model):
|
||||
profile_owner = models.ForeignKey(User, related_name='profile_comments', on_delete=models.CASCADE)
|
||||
author = models.ForeignKey(User, related_name='authored_profile_comments', on_delete=models.CASCADE)
|
||||
text = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
class Banner(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
slug = models.SlugField(unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
is_active = models.BooleanField(default=False, help_text="Designates this as the global default banner.")
|
||||
is_enabled = models.BooleanField(default=True, help_text="Allows users to select this banner.")
|
||||
settings = models.JSONField(default=dict, blank=True)
|
||||
schema = models.JSONField(default=list, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.is_active:
|
||||
# Set all others to inactive
|
||||
Banner.objects.filter(is_active=True).exclude(pk=self.pk).update(is_active=False)
|
||||
elif not Banner.objects.filter(is_active=True).exclude(pk=self.pk).exists():
|
||||
# If this was the only active banner, don't allow deactivating it
|
||||
# unless there's another one to take its place.
|
||||
self.is_active = True
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class SystemConfig(models.Model):
|
||||
"""Singleton model for application-wide settings editable via UI."""
|
||||
site_name = models.CharField(max_length=100, default="Holy Flavors Archive")
|
||||
maintenance_mode = models.BooleanField(default=False)
|
||||
allow_new_signups = models.BooleanField(default=True)
|
||||
require_email_verification = models.BooleanField(default=True)
|
||||
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.pk = 1 # Force singleton
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def get_solo(cls):
|
||||
obj, _ = cls.objects.get_or_create(pk=1)
|
||||
return obj
|
||||
|
||||
class Meta:
|
||||
verbose_name = "System Configuration"
|
||||
|
||||
class Job(models.Model):
|
||||
"""Tracks background jobs, their status and scheduling."""
|
||||
JOB_TYPES = [
|
||||
('sync_flavors', 'Sync Holy Flavors'),
|
||||
('cleanup_duplicates', 'Cleanup Duplicate Flavors'),
|
||||
('backup_db', 'Database Backup'),
|
||||
('seed_legacy', 'Seed Legacy Data'),
|
||||
]
|
||||
STATUS_CHOICES = [
|
||||
('pending', 'Pending'),
|
||||
('running', 'Running'),
|
||||
('completed', 'Completed'),
|
||||
('failed', 'Failed'),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=100, choices=JOB_TYPES)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
|
||||
last_run = models.DateTimeField(null=True, blank=True)
|
||||
next_run = models.DateTimeField(null=True, blank=True)
|
||||
interval_hours = models.IntegerField(default=0, help_text="Set to 0 to disable periodic run.")
|
||||
|
||||
last_output = models.TextField(blank=True)
|
||||
error_message = models.TextField(blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_name_display()} ({self.status})"
|
||||
@@ -0,0 +1,22 @@
|
||||
from api.models.flavor import Category, Flavor
|
||||
from api.models.rating import Rating, Reply
|
||||
from api.models.social import Notification, ProfileComment
|
||||
from api.models.support import Ticket, TicketMessage
|
||||
from api.models.system import Banner, Job, SystemConfig
|
||||
from api.models.user import User, UserIP
|
||||
|
||||
__all__ = [
|
||||
"Banner",
|
||||
"Category",
|
||||
"Flavor",
|
||||
"Job",
|
||||
"Notification",
|
||||
"ProfileComment",
|
||||
"Rating",
|
||||
"Reply",
|
||||
"SystemConfig",
|
||||
"Ticket",
|
||||
"TicketMessage",
|
||||
"User",
|
||||
"UserIP",
|
||||
]
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,30 @@
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Rating(models.Model):
|
||||
user = models.ForeignKey("api.User", related_name="ratings", on_delete=models.CASCADE)
|
||||
flavor = models.ForeignKey("api.Flavor", related_name="ratings", on_delete=models.CASCADE)
|
||||
score = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(10)])
|
||||
comment = models.TextField(blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("user", "flavor")
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user.username} - {self.flavor.name}: {self.score}"
|
||||
|
||||
|
||||
class Reply(models.Model):
|
||||
user = models.ForeignKey("api.User", related_name="replies", on_delete=models.CASCADE)
|
||||
rating = models.ForeignKey(Rating, related_name="replies", on_delete=models.CASCADE)
|
||||
text = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Reply by {self.user.username} on {self.rating.id}"
|
||||
@@ -0,0 +1,45 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Notification(models.Model):
|
||||
TYPE_CHOICES = [
|
||||
("reply", "Reply"),
|
||||
("mention", "Mention"),
|
||||
("follow", "Follow"),
|
||||
("ticket_new", "New Ticket"),
|
||||
("ticket_reply", "Ticket Reply"),
|
||||
("profile_comment", "Profile Comment"),
|
||||
]
|
||||
recipient = models.ForeignKey(
|
||||
"api.User", related_name="notifications", on_delete=models.CASCADE
|
||||
)
|
||||
actor = models.ForeignKey("api.User", related_name="actions", on_delete=models.CASCADE)
|
||||
notification_type = models.CharField(max_length=20, choices=TYPE_CHOICES)
|
||||
rating = models.ForeignKey("api.Rating", on_delete=models.CASCADE, null=True, blank=True)
|
||||
reply = models.ForeignKey("api.Reply", on_delete=models.CASCADE, null=True, blank=True)
|
||||
ticket = models.ForeignKey("api.Ticket", on_delete=models.CASCADE, null=True, blank=True)
|
||||
profile_comment = models.ForeignKey(
|
||||
"api.ProfileComment", on_delete=models.CASCADE, null=True, blank=True
|
||||
)
|
||||
is_read = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.notification_type} for {self.recipient.username}"
|
||||
|
||||
|
||||
class ProfileComment(models.Model):
|
||||
profile_owner = models.ForeignKey(
|
||||
"api.User", related_name="profile_comments", on_delete=models.CASCADE
|
||||
)
|
||||
author = models.ForeignKey(
|
||||
"api.User", related_name="authored_profile_comments", on_delete=models.CASCADE
|
||||
)
|
||||
text = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
@@ -0,0 +1,29 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Ticket(models.Model):
|
||||
STATUS_CHOICES = [
|
||||
("open", "Open"),
|
||||
("in_progress", "In Progress"),
|
||||
("resolved", "Resolved"),
|
||||
("closed", "Closed"),
|
||||
]
|
||||
user = models.ForeignKey("api.User", related_name="tickets", on_delete=models.CASCADE)
|
||||
subject = models.CharField(max_length=200)
|
||||
description = models.TextField()
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="open")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-updated_at"]
|
||||
|
||||
|
||||
class TicketMessage(models.Model):
|
||||
ticket = models.ForeignKey(Ticket, related_name="messages", on_delete=models.CASCADE)
|
||||
user = models.ForeignKey("api.User", on_delete=models.CASCADE)
|
||||
text = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["created_at"]
|
||||
@@ -0,0 +1,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})"
|
||||
@@ -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"]
|
||||
@@ -1,237 +0,0 @@
|
||||
from rest_framework import serializers
|
||||
from .models import User, Flavor, Category, Rating, Reply, Notification, Ticket, TicketMessage, UserIP, Banner, SystemConfig, Job
|
||||
|
||||
class CategorySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Category
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
class SystemConfigSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = SystemConfig
|
||||
fields = ['site_name', 'maintenance_mode', 'allow_new_signups', 'require_email_verification', 'updated_at']
|
||||
|
||||
class JobSerializer(serializers.ModelSerializer):
|
||||
name_display = serializers.CharField(source='get_name_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Job
|
||||
fields = ['id', 'name', 'name_display', 'status', 'last_run', 'next_run', 'interval_hours', 'last_output', 'error_message']
|
||||
read_only_fields = ['status', 'last_run', 'next_run', 'last_output', 'error_message']
|
||||
|
||||
class BannerSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Banner
|
||||
fields = ['id', 'name', 'slug', 'description', 'is_active', 'is_enabled', 'settings', 'schema', 'created_at', 'updated_at']
|
||||
|
||||
class ReplySerializer(serializers.ModelSerializer):
|
||||
user = serializers.StringRelatedField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Reply
|
||||
fields = ['id', 'user', 'rating', 'text', 'created_at']
|
||||
read_only_fields = ['rating']
|
||||
|
||||
class RatingSerializer(serializers.ModelSerializer):
|
||||
user = serializers.StringRelatedField(read_only=True)
|
||||
user_id = serializers.IntegerField(source='user.id', read_only=True)
|
||||
user_avatar = serializers.SerializerMethodField()
|
||||
flavor_name = serializers.CharField(source='flavor.name', read_only=True)
|
||||
flavor_image = serializers.SerializerMethodField()
|
||||
category_name = serializers.CharField(source='flavor.category.name', read_only=True)
|
||||
category_slug = serializers.CharField(source='flavor.category.slug', read_only=True)
|
||||
is_available = serializers.BooleanField(source='flavor.is_available', read_only=True)
|
||||
is_legacy = serializers.BooleanField(source='flavor.is_legacy', read_only=True)
|
||||
replies = ReplySerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Rating
|
||||
fields = ['id', 'user', 'user_id', 'user_avatar', 'flavor', 'flavor_name', 'flavor_image', 'category_name', 'category_slug', 'is_available', 'is_legacy', 'score', 'comment', 'created_at', 'replies']
|
||||
|
||||
def get_user_avatar(self, obj):
|
||||
if obj.user.avatar:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.user.avatar.url)
|
||||
return obj.user.avatar.url
|
||||
return None
|
||||
|
||||
def get_flavor_image(self, obj):
|
||||
if obj.flavor.image:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.flavor.image.url)
|
||||
return obj.flavor.image.url
|
||||
return obj.flavor.image_url
|
||||
|
||||
class FlavorSerializer(serializers.ModelSerializer):
|
||||
category_name = serializers.CharField(source='category.name', read_only=True)
|
||||
category_slug = serializers.CharField(source='category.slug', read_only=True)
|
||||
average_rating = serializers.FloatField(read_only=True)
|
||||
followed_average_rating = serializers.FloatField(read_only=True)
|
||||
user_rating = serializers.SerializerMethodField()
|
||||
ratings = RatingSerializer(many=True, read_only=True)
|
||||
image_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Flavor
|
||||
fields = ['id', 'name', 'category', 'category_name', 'category_slug', 'description', 'average_rating', 'followed_average_rating', 'user_rating', 'ratings', 'image_url', 'image', 'is_available', 'is_legacy', 'shop_url']
|
||||
extra_kwargs = {
|
||||
'image': {'write_only': True}
|
||||
}
|
||||
|
||||
def get_image_url(self, obj):
|
||||
if obj.image:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.image.url)
|
||||
return obj.image.url
|
||||
return obj.image_url
|
||||
|
||||
def get_user_rating(self, obj):
|
||||
request = self.context.get('request')
|
||||
if request and request.user.is_authenticated:
|
||||
try:
|
||||
rating = request.user.ratings.get(flavor=obj)
|
||||
return rating.score
|
||||
except Rating.DoesNotExist:
|
||||
return None
|
||||
return None
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
avatar = serializers.SerializerMethodField()
|
||||
following_count = serializers.IntegerField(source='following.count', read_only=True)
|
||||
followers_count = serializers.IntegerField(source='followers.count', read_only=True)
|
||||
is_following = serializers.SerializerMethodField()
|
||||
unread_notifications_count = serializers.SerializerMethodField()
|
||||
is_superuser = serializers.BooleanField(read_only=True)
|
||||
selected_banner_slug = serializers.CharField(source='selected_banner.slug', read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'email', 'theme', 'language', 'avatar', 'following_count', 'followers_count', 'is_following', 'unread_notifications_count', 'is_superuser', 'selected_banner', 'selected_banner_slug']
|
||||
|
||||
def get_avatar(self, obj):
|
||||
if obj.avatar:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.avatar.url)
|
||||
return obj.avatar.url
|
||||
return None
|
||||
|
||||
def get_is_following(self, obj):
|
||||
request = self.context.get('request')
|
||||
if request and request.user.is_authenticated:
|
||||
return request.user.following.filter(pk=obj.pk).exists()
|
||||
return False
|
||||
|
||||
def get_unread_notifications_count(self, obj):
|
||||
return obj.notifications.filter(is_read=False).count()
|
||||
|
||||
class UserIPSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = UserIP
|
||||
fields = ['id', 'ip_address', 'last_login']
|
||||
|
||||
class TicketMessageSerializer(serializers.ModelSerializer):
|
||||
username = serializers.CharField(source='user.username', read_only=True)
|
||||
is_admin = serializers.BooleanField(source='user.is_superuser', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = TicketMessage
|
||||
fields = ['id', 'username', 'text', 'created_at', 'is_admin']
|
||||
|
||||
class TicketSerializer(serializers.ModelSerializer):
|
||||
username = serializers.CharField(source='user.username', read_only=True)
|
||||
user_email = serializers.EmailField(source='user.email', read_only=True)
|
||||
user_avatar = serializers.SerializerMethodField()
|
||||
messages = TicketMessageSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Ticket
|
||||
fields = ['id', 'user', 'username', 'user_email', 'user_avatar', 'subject', 'description', 'status', 'created_at', 'updated_at', 'messages']
|
||||
read_only_fields = ['status', 'user']
|
||||
|
||||
def get_user_avatar(self, obj):
|
||||
if obj.user.avatar:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.user.avatar.url)
|
||||
return obj.user.avatar.url
|
||||
return None
|
||||
|
||||
class ProfileCommentSerializer(serializers.ModelSerializer):
|
||||
author_username = serializers.CharField(source='author.username', read_only=True)
|
||||
author_avatar = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
from .models import ProfileComment
|
||||
model = ProfileComment
|
||||
fields = ['id', 'author_username', 'author_avatar', 'text', 'created_at']
|
||||
|
||||
def get_author_avatar(self, obj):
|
||||
if obj.author.avatar:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.author.avatar.url)
|
||||
return obj.author.avatar.url
|
||||
return None
|
||||
|
||||
class AdminUserListSerializer(serializers.ModelSerializer):
|
||||
ips = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'email', 'is_active', 'is_superuser', 'date_joined', 'last_login', 'ips']
|
||||
|
||||
def get_ips(self, obj):
|
||||
return [ip.ip_address for ip in obj.ips.all()[:3]]
|
||||
|
||||
class AdminUserDetailSerializer(serializers.ModelSerializer):
|
||||
ips = serializers.SerializerMethodField()
|
||||
ratings = RatingSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'email', 'is_active', 'is_superuser', 'date_joined', 'last_login', 'ips', 'ratings']
|
||||
|
||||
def get_ips(self, obj):
|
||||
return [ip.ip_address for ip in obj.ips.all()]
|
||||
|
||||
class NotificationSerializer(serializers.ModelSerializer):
|
||||
actor_username = serializers.CharField(source='actor.username', read_only=True)
|
||||
actor_avatar = serializers.SerializerMethodField()
|
||||
flavor_name = serializers.SerializerMethodField()
|
||||
flavor_id = serializers.SerializerMethodField()
|
||||
profile_owner_username = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Notification
|
||||
fields = ['id', 'actor_username', 'actor_avatar', 'notification_type', 'rating', 'reply', 'ticket', 'profile_comment', 'profile_owner_username', 'is_read', 'created_at', 'flavor_name', 'flavor_id']
|
||||
|
||||
def get_actor_avatar(self, obj):
|
||||
if obj.actor.avatar:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.actor.avatar.url)
|
||||
return obj.actor.avatar.url
|
||||
return None
|
||||
|
||||
def get_flavor_name(self, obj):
|
||||
if obj.rating:
|
||||
return obj.rating.flavor.name
|
||||
if obj.reply:
|
||||
return obj.reply.rating.flavor.name
|
||||
return None
|
||||
|
||||
def get_flavor_id(self, obj):
|
||||
if obj.rating:
|
||||
return obj.rating.flavor.id
|
||||
if obj.reply:
|
||||
return obj.reply.rating.flavor.id
|
||||
return None
|
||||
|
||||
def get_profile_owner_username(self, obj):
|
||||
if obj.profile_comment:
|
||||
return obj.profile_comment.profile_owner.username
|
||||
return None
|
||||
@@ -0,0 +1,33 @@
|
||||
from api.serializers.flavor import CategorySerializer, FlavorSerializer
|
||||
from api.serializers.rating import RatingSerializer, ReplySerializer
|
||||
from api.serializers.social import NotificationSerializer, ProfileCommentSerializer
|
||||
from api.serializers.support import TicketMessageSerializer, TicketSerializer
|
||||
from api.serializers.system import (
|
||||
BannerSerializer,
|
||||
JobSerializer,
|
||||
SystemConfigSerializer,
|
||||
)
|
||||
from api.serializers.user import (
|
||||
AdminUserDetailSerializer,
|
||||
AdminUserListSerializer,
|
||||
UserIPSerializer,
|
||||
UserSerializer,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AdminUserDetailSerializer",
|
||||
"AdminUserListSerializer",
|
||||
"BannerSerializer",
|
||||
"CategorySerializer",
|
||||
"FlavorSerializer",
|
||||
"JobSerializer",
|
||||
"NotificationSerializer",
|
||||
"ProfileCommentSerializer",
|
||||
"RatingSerializer",
|
||||
"ReplySerializer",
|
||||
"SystemConfigSerializer",
|
||||
"TicketMessageSerializer",
|
||||
"TicketSerializer",
|
||||
"UserIPSerializer",
|
||||
"UserSerializer",
|
||||
]
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
@@ -0,0 +1,54 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from api.models import Rating, Reply
|
||||
from api.serializers._helpers import absolute_image_url
|
||||
|
||||
|
||||
class ReplySerializer(serializers.ModelSerializer):
|
||||
user = serializers.StringRelatedField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Reply
|
||||
fields = ["id", "user", "rating", "text", "created_at"]
|
||||
read_only_fields = ["rating"]
|
||||
|
||||
|
||||
class RatingSerializer(serializers.ModelSerializer):
|
||||
user = serializers.StringRelatedField(read_only=True)
|
||||
user_id = serializers.IntegerField(source="user.id", read_only=True)
|
||||
user_avatar = serializers.SerializerMethodField()
|
||||
flavor_name = serializers.CharField(source="flavor.name", read_only=True)
|
||||
flavor_image = serializers.SerializerMethodField()
|
||||
category_name = serializers.CharField(source="flavor.category.name", read_only=True)
|
||||
category_slug = serializers.CharField(source="flavor.category.slug", read_only=True)
|
||||
is_available = serializers.BooleanField(source="flavor.is_available", read_only=True)
|
||||
is_legacy = serializers.BooleanField(source="flavor.is_legacy", read_only=True)
|
||||
replies = ReplySerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Rating
|
||||
fields = [
|
||||
"id",
|
||||
"user",
|
||||
"user_id",
|
||||
"user_avatar",
|
||||
"flavor",
|
||||
"flavor_name",
|
||||
"flavor_image",
|
||||
"category_name",
|
||||
"category_slug",
|
||||
"is_available",
|
||||
"is_legacy",
|
||||
"score",
|
||||
"comment",
|
||||
"created_at",
|
||||
"replies",
|
||||
]
|
||||
|
||||
def get_user_avatar(self, obj: Rating) -> str | None:
|
||||
return absolute_image_url(self, obj.user.avatar)
|
||||
|
||||
def get_flavor_image(self, obj: Rating) -> str | None:
|
||||
if obj.flavor.image:
|
||||
return absolute_image_url(self, obj.flavor.image)
|
||||
return obj.flavor.image_url
|
||||
@@ -0,0 +1,64 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from api.models import Notification, ProfileComment
|
||||
from api.serializers._helpers import absolute_image_url
|
||||
|
||||
|
||||
class ProfileCommentSerializer(serializers.ModelSerializer):
|
||||
author_username = serializers.CharField(source="author.username", read_only=True)
|
||||
author_avatar = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ProfileComment
|
||||
fields = ["id", "author_username", "author_avatar", "text", "created_at"]
|
||||
|
||||
def get_author_avatar(self, obj: ProfileComment) -> str | None:
|
||||
return absolute_image_url(self, obj.author.avatar)
|
||||
|
||||
|
||||
class NotificationSerializer(serializers.ModelSerializer):
|
||||
actor_username = serializers.CharField(source="actor.username", read_only=True)
|
||||
actor_avatar = serializers.SerializerMethodField()
|
||||
flavor_name = serializers.SerializerMethodField()
|
||||
flavor_id = serializers.SerializerMethodField()
|
||||
profile_owner_username = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Notification
|
||||
fields = [
|
||||
"id",
|
||||
"actor_username",
|
||||
"actor_avatar",
|
||||
"notification_type",
|
||||
"rating",
|
||||
"reply",
|
||||
"ticket",
|
||||
"profile_comment",
|
||||
"profile_owner_username",
|
||||
"is_read",
|
||||
"created_at",
|
||||
"flavor_name",
|
||||
"flavor_id",
|
||||
]
|
||||
|
||||
def get_actor_avatar(self, obj: Notification) -> str | None:
|
||||
return absolute_image_url(self, obj.actor.avatar)
|
||||
|
||||
def get_flavor_name(self, obj: Notification) -> str | None:
|
||||
if obj.rating:
|
||||
return obj.rating.flavor.name
|
||||
if obj.reply:
|
||||
return obj.reply.rating.flavor.name
|
||||
return None
|
||||
|
||||
def get_flavor_id(self, obj: Notification) -> int | None:
|
||||
if obj.rating:
|
||||
return obj.rating.flavor.id
|
||||
if obj.reply:
|
||||
return obj.reply.rating.flavor.id
|
||||
return None
|
||||
|
||||
def get_profile_owner_username(self, obj: Notification) -> str | None:
|
||||
if obj.profile_comment:
|
||||
return obj.profile_comment.profile_owner.username
|
||||
return None
|
||||
@@ -0,0 +1,40 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from api.models import Ticket, TicketMessage
|
||||
from api.serializers._helpers import absolute_image_url
|
||||
|
||||
|
||||
class TicketMessageSerializer(serializers.ModelSerializer):
|
||||
username = serializers.CharField(source="user.username", read_only=True)
|
||||
is_admin = serializers.BooleanField(source="user.is_superuser", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = TicketMessage
|
||||
fields = ["id", "username", "text", "created_at", "is_admin"]
|
||||
|
||||
|
||||
class TicketSerializer(serializers.ModelSerializer):
|
||||
username = serializers.CharField(source="user.username", read_only=True)
|
||||
user_email = serializers.EmailField(source="user.email", read_only=True)
|
||||
user_avatar = serializers.SerializerMethodField()
|
||||
messages = TicketMessageSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Ticket
|
||||
fields = [
|
||||
"id",
|
||||
"user",
|
||||
"username",
|
||||
"user_email",
|
||||
"user_avatar",
|
||||
"subject",
|
||||
"description",
|
||||
"status",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"messages",
|
||||
]
|
||||
read_only_fields = ["status", "user"]
|
||||
|
||||
def get_user_avatar(self, obj: Ticket) -> str | None:
|
||||
return absolute_image_url(self, obj.user.avatar)
|
||||
@@ -0,0 +1,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",
|
||||
]
|
||||
@@ -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()]
|
||||
Whitespace-only changes.
@@ -0,0 +1,14 @@
|
||||
from rest_framework.request import Request
|
||||
|
||||
from api.models import User, UserIP
|
||||
|
||||
|
||||
def log_user_ip(user: User, request: Request) -> None:
|
||||
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(",")[0].strip()
|
||||
else:
|
||||
ip = request.META.get("REMOTE_ADDR")
|
||||
|
||||
if ip and user.is_authenticated:
|
||||
UserIP.objects.update_or_create(user=user, ip_address=ip)
|
||||
@@ -0,0 +1,29 @@
|
||||
import re
|
||||
|
||||
from api.models import Notification, Rating, Reply, User
|
||||
|
||||
_MENTION_RE = re.compile(r"@(\w+)")
|
||||
|
||||
|
||||
def parse_mentions(
|
||||
text: str,
|
||||
actor: User,
|
||||
rating: Rating | None = None,
|
||||
reply: Reply | None = None,
|
||||
) -> None:
|
||||
if not text:
|
||||
return
|
||||
for username in set(_MENTION_RE.findall(text)):
|
||||
try:
|
||||
recipient = User.objects.get(username__iexact=username)
|
||||
except User.DoesNotExist:
|
||||
continue
|
||||
if recipient == actor:
|
||||
continue
|
||||
Notification.objects.get_or_create(
|
||||
recipient=recipient,
|
||||
actor=actor,
|
||||
notification_type="mention",
|
||||
rating=rating,
|
||||
reply=reply,
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
from django.db.models import Q, QuerySet
|
||||
|
||||
from api.models import Flavor
|
||||
|
||||
CATEGORY_KEYWORDS: dict[str, str] = {
|
||||
"iced tea": "iced-tea",
|
||||
"eistee": "iced-tea",
|
||||
"energy": "energy",
|
||||
"hydration": "hydration",
|
||||
"milkshake": "milkshake",
|
||||
}
|
||||
|
||||
|
||||
def extract_category_slug(query: str) -> tuple[str | None, str]:
|
||||
lowered = query.lower().strip()
|
||||
for keyword, slug in CATEGORY_KEYWORDS.items():
|
||||
if keyword in lowered:
|
||||
return slug, lowered.replace(keyword, "").strip()
|
||||
return None, lowered
|
||||
|
||||
|
||||
def filter_flavors_by_query(queryset: QuerySet[Flavor], query: str) -> QuerySet[Flavor]:
|
||||
slug, remaining = extract_category_slug(query)
|
||||
if slug:
|
||||
queryset = queryset.filter(category__slug=slug)
|
||||
if remaining:
|
||||
word_query = Q()
|
||||
for word in remaining.split():
|
||||
if len(word) > 2:
|
||||
word_query |= Q(name__icontains=word) | Q(description__icontains=word)
|
||||
if word_query:
|
||||
queryset = queryset.filter(word_query)
|
||||
return queryset
|
||||
|
||||
|
||||
def score_relevance(name: str, query: str) -> int:
|
||||
name_lower = name.lower()
|
||||
if name_lower == query:
|
||||
return 3
|
||||
if name_lower.startswith(query):
|
||||
return 2
|
||||
if query in name_lower:
|
||||
return 1
|
||||
return 0
|
||||
@@ -0,0 +1,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,
|
||||
)
|
||||
@@ -1,45 +0,0 @@
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
from .models import Category, Flavor, User, Rating
|
||||
|
||||
class FlavorTopTests(APITestCase):
|
||||
def setUp(self):
|
||||
self.category_energy = Category.objects.create(name='Energy', slug='energy')
|
||||
self.category_iced_tea = Category.objects.create(name='Iced Tea', slug='iced-tea')
|
||||
|
||||
self.user = User.objects.create_user(username='testuser', password='password')
|
||||
|
||||
# Create flavors
|
||||
self.flavor1 = Flavor.objects.create(name='Energy 1', category=self.category_energy)
|
||||
self.flavor2 = Flavor.objects.create(name='Iced Tea 1', category=self.category_iced_tea)
|
||||
|
||||
# Add ratings to make them appear in top (since we filter by ratings__isnull=False)
|
||||
Rating.objects.create(user=self.user, flavor=self.flavor1, score=5)
|
||||
Rating.objects.create(user=self.user, flavor=self.flavor2, score=4)
|
||||
|
||||
def test_top_flavors_all(self):
|
||||
url = reverse('flavor-top')
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
def test_top_flavors_filtered_energy(self):
|
||||
url = reverse('flavor-top')
|
||||
response = self.client.get(url, {'category': 'energy'})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]['name'], 'Energy 1')
|
||||
|
||||
def test_top_flavors_filtered_iced_tea(self):
|
||||
url = reverse('flavor-top')
|
||||
response = self.client.get(url, {'category': 'iced-tea'})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]['name'], 'Iced Tea 1')
|
||||
|
||||
def test_top_flavors_filtered_non_existent(self):
|
||||
url = reverse('flavor-top')
|
||||
response = self.client.get(url, {'category': 'non-existent'})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 0)
|
||||
Whitespace-only changes.
@@ -0,0 +1,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, "")
|
||||
@@ -0,0 +1,45 @@
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from api.models import Category, Flavor, Rating, User
|
||||
|
||||
|
||||
class FlavorTopTests(APITestCase):
|
||||
def setUp(self) -> None:
|
||||
self.category_energy = Category.objects.create(name="Energy", slug="energy")
|
||||
self.category_iced_tea = Category.objects.create(name="Iced Tea", slug="iced-tea")
|
||||
|
||||
self.user = User.objects.create_user(username="testuser", password="password")
|
||||
|
||||
self.flavor1 = Flavor.objects.create(name="Energy 1", category=self.category_energy)
|
||||
self.flavor2 = Flavor.objects.create(name="Iced Tea 1", category=self.category_iced_tea)
|
||||
|
||||
Rating.objects.create(user=self.user, flavor=self.flavor1, score=5)
|
||||
Rating.objects.create(user=self.user, flavor=self.flavor2, score=4)
|
||||
|
||||
def test_top_flavors_all(self) -> None:
|
||||
url = reverse("flavor-top")
|
||||
response = self.client.get(url, follow=True)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
def test_top_flavors_filtered_energy(self) -> None:
|
||||
url = reverse("flavor-top")
|
||||
response = self.client.get(url, {"category": "energy"}, follow=True)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]["name"], "Energy 1")
|
||||
|
||||
def test_top_flavors_filtered_iced_tea(self) -> None:
|
||||
url = reverse("flavor-top")
|
||||
response = self.client.get(url, {"category": "iced-tea"}, follow=True)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]["name"], "Iced Tea 1")
|
||||
|
||||
def test_top_flavors_filtered_non_existent(self) -> None:
|
||||
url = reverse("flavor-top")
|
||||
response = self.client.get(url, {"category": "non-existent"}, follow=True)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 0)
|
||||
@@ -0,0 +1,19 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from api.models import Category, Flavor, Rating, User
|
||||
|
||||
|
||||
class FlavorAverageRatingTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.category = Category.objects.create(name="Energy", slug="energy")
|
||||
self.flavor = Flavor.objects.create(name="Test", category=self.category)
|
||||
self.u1 = User.objects.create_user(username="u1", password="x")
|
||||
self.u2 = User.objects.create_user(username="u2", password="x")
|
||||
|
||||
def test_average_zero_when_no_ratings(self) -> None:
|
||||
self.assertEqual(self.flavor.get_average_rating, 0.0)
|
||||
|
||||
def test_average_uses_aggregate(self) -> None:
|
||||
Rating.objects.create(user=self.u1, flavor=self.flavor, score=4)
|
||||
Rating.objects.create(user=self.u2, flavor=self.flavor, score=8)
|
||||
self.assertEqual(self.flavor.get_average_rating, 6.0)
|
||||
+12
-11
@@ -1,18 +1,19 @@
|
||||
from django.urls import path, include
|
||||
from django.urls import include, path
|
||||
from rest_framework import routers
|
||||
|
||||
from . import views
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
router.register('users', views.UserViewSet)
|
||||
router.register('flavors', views.FlavorViewSet)
|
||||
router.register('categories', views.CategoryViewSet)
|
||||
router.register('ratings', views.RatingViewSet)
|
||||
router.register('replies', views.ReplyViewSet)
|
||||
router.register('notifications', views.NotificationViewSet, basename='notification')
|
||||
router.register('tickets', views.TicketViewSet, basename='ticket')
|
||||
router.register('admin-custom', views.AdminViewSet, basename='admin-custom')
|
||||
router.register('banners', views.BannerViewSet, basename='banner')
|
||||
router.register("users", views.UserViewSet)
|
||||
router.register("flavors", views.FlavorViewSet)
|
||||
router.register("categories", views.CategoryViewSet)
|
||||
router.register("ratings", views.RatingViewSet)
|
||||
router.register("replies", views.ReplyViewSet)
|
||||
router.register("notifications", views.NotificationViewSet, basename="notification")
|
||||
router.register("tickets", views.TicketViewSet, basename="ticket")
|
||||
router.register("admin-custom", views.AdminViewSet, basename="admin-custom")
|
||||
router.register("banners", views.BannerViewSet, basename="banner")
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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)
|
||||
@@ -1,979 +0,0 @@
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from rest_framework import viewsets, permissions, status, filters, serializers
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import action
|
||||
from django.db.models import Avg, Q
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django_ratelimit.decorators import ratelimit
|
||||
from django.utils.decorators import method_decorator
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from .models import User, Flavor, Category, Rating, Reply, Notification, Ticket, TicketMessage, UserIP, ProfileComment, Banner, SystemConfig, Job
|
||||
from .serializers import (
|
||||
UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer,
|
||||
ReplySerializer, NotificationSerializer, TicketSerializer,
|
||||
TicketMessageSerializer, AdminUserListSerializer, AdminUserDetailSerializer,
|
||||
ProfileCommentSerializer, BannerSerializer, SystemConfigSerializer, JobSerializer
|
||||
)
|
||||
|
||||
def log_user_ip(user, request):
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0]
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
|
||||
if ip and user.is_authenticated:
|
||||
UserIP.objects.update_or_create(user=user, ip_address=ip)
|
||||
|
||||
def parse_mentions(text, actor, rating=None, reply=None):
|
||||
import re
|
||||
mentions = re.findall(r'@(\w+)', text)
|
||||
for username in set(mentions):
|
||||
try:
|
||||
recipient = User.objects.get(username__iexact=username)
|
||||
if recipient != actor:
|
||||
Notification.objects.get_or_create(
|
||||
recipient=recipient,
|
||||
actor=actor,
|
||||
notification_type='mention',
|
||||
rating=rating,
|
||||
reply=reply
|
||||
)
|
||||
except User.DoesNotExist:
|
||||
continue
|
||||
|
||||
class FeedPagination(PageNumberPagination):
|
||||
page_size = 10
|
||||
|
||||
class FlavorViewSet(viewsets.ModelViewSet):
|
||||
queryset = Flavor.objects.annotate(average_rating=Avg('ratings__score')).order_by('-average_rating')
|
||||
serializer_class = FlavorSerializer
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
|
||||
filterset_fields = ['category', 'category__slug']
|
||||
search_fields = ['name', 'description']
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
|
||||
def get_queryset(self):
|
||||
qs = Flavor.objects.select_related('category').annotate(average_rating=Avg('ratings__score')).order_by('-average_rating').distinct()
|
||||
|
||||
# Handle category keywords in search query for main results too
|
||||
search_query = self.request.query_params.get('search', '').lower().strip()
|
||||
if search_query:
|
||||
cat_keywords = {
|
||||
'iced tea': 'iced-tea',
|
||||
'eistee': 'iced-tea',
|
||||
'energy': 'energy',
|
||||
'hydration': 'hydration',
|
||||
'milkshake': 'milkshake'
|
||||
}
|
||||
for keyword, slug in cat_keywords.items():
|
||||
if keyword in search_query:
|
||||
qs = qs.filter(category__slug=slug)
|
||||
break # Only one category at a time
|
||||
|
||||
return qs
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
|
||||
def search(self, request):
|
||||
query = request.query_params.get('q', '').lower().strip()
|
||||
if not query:
|
||||
return Response([])
|
||||
|
||||
# Start with all flavors
|
||||
flavors = Flavor.objects.select_related('category').distinct()
|
||||
|
||||
# Intelligent category filtering based on keywords
|
||||
cat_keywords = {
|
||||
'iced tea': 'iced-tea',
|
||||
'eistee': 'iced-tea',
|
||||
'energy': 'energy',
|
||||
'hydration': 'hydration',
|
||||
'milkshake': 'milkshake'
|
||||
}
|
||||
|
||||
active_cat_slug = None
|
||||
remaining_query = query
|
||||
for keyword, slug in cat_keywords.items():
|
||||
if keyword in query:
|
||||
active_cat_slug = slug
|
||||
remaining_query = remaining_query.replace(keyword, '').strip()
|
||||
break
|
||||
|
||||
if active_cat_slug:
|
||||
flavors = flavors.filter(category__slug=active_cat_slug)
|
||||
|
||||
# Filter by remaining query words in name or description
|
||||
if remaining_query:
|
||||
words = remaining_query.split()
|
||||
# Construct a combined OR query for all words to be more permissive (fuzzy-ish)
|
||||
word_query = Q()
|
||||
for word in words:
|
||||
if len(word) > 2: # Only filter by words longer than 2 chars to avoid noise
|
||||
word_query |= Q(name__icontains=word) | Q(description__icontains=word)
|
||||
|
||||
if word_query:
|
||||
flavors = flavors.filter(word_query)
|
||||
|
||||
# Limit results and deduplicate in Python to be safe against DB inconsistencies
|
||||
results_list = []
|
||||
seen_keys = set()
|
||||
|
||||
# Priority sorting in Python
|
||||
search_lower = remaining_query.lower()
|
||||
|
||||
# Order by name, but we'll also try to prioritize exact matches in Python
|
||||
for f in flavors.order_by('name'):
|
||||
# Create a unique key for this flavor (name + category)
|
||||
key = f"{f.name.strip().lower()}|{f.category.slug}"
|
||||
if key in seen_keys:
|
||||
continue
|
||||
|
||||
seen_keys.add(key)
|
||||
|
||||
name_lower = f.name.lower()
|
||||
relevance = 0
|
||||
if name_lower == search_lower:
|
||||
relevance = 3
|
||||
elif name_lower.startswith(search_lower):
|
||||
relevance = 2
|
||||
elif search_lower in name_lower:
|
||||
relevance = 1
|
||||
|
||||
results_list.append({
|
||||
'id': f.id,
|
||||
'name': f.name,
|
||||
'type': 'flavor',
|
||||
'subtitle': f.category.name,
|
||||
'image_url': request.build_absolute_uri(f.image.url) if f.image else f.image_url,
|
||||
'slug': None,
|
||||
'relevance': relevance
|
||||
})
|
||||
|
||||
# Sort by relevance desc, then name
|
||||
results_list.sort(key=lambda x: (-x['relevance'], x['name']))
|
||||
|
||||
# Remove relevance helper and limit to 15
|
||||
final_results = []
|
||||
for r in results_list:
|
||||
del r['relevance']
|
||||
final_results.append(r)
|
||||
if len(final_results) >= 15:
|
||||
break
|
||||
|
||||
return Response(final_results)
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
|
||||
def top(self, request):
|
||||
queryset = self.get_queryset().filter(ratings__isnull=False)
|
||||
category_slug = request.query_params.get('category')
|
||||
if category_slug:
|
||||
queryset = queryset.filter(category__slug=category_slug)
|
||||
top_flavors = queryset.distinct()[:10]
|
||||
serializer = self.get_serializer(top_flavors, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
|
||||
def newest(self, request):
|
||||
# Get the 10 newest flavors
|
||||
newest_flavors = Flavor.objects.select_related('category').order_by('-created_at')[:10]
|
||||
serializer = self.get_serializer(newest_flavors, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
|
||||
def followed_top(self, request):
|
||||
followed_users = request.user.following.all()
|
||||
top_flavors = Flavor.objects.filter(ratings__user__in=followed_users) \
|
||||
.annotate(average_rating=Avg('ratings__score')) \
|
||||
.order_by('-average_rating') \
|
||||
.distinct()[:10]
|
||||
serializer = self.get_serializer(top_flavors, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = Category.objects.all()
|
||||
serializer_class = CategorySerializer
|
||||
|
||||
class RatingViewSet(viewsets.ModelViewSet):
|
||||
queryset = Rating.objects.all()
|
||||
serializer_class = RatingSerializer
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
|
||||
@method_decorator(ratelimit(key='user', rate='10/m', method='POST', block=True))
|
||||
def create(self, request, *args, **kwargs):
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
# Ensure user can only have one rating per flavor
|
||||
flavor = serializer.validated_data['flavor']
|
||||
if Rating.objects.filter(user=self.request.user, flavor=flavor).exists():
|
||||
raise serializers.ValidationError('You have already rated this flavor.')
|
||||
rating = serializer.save(user=self.request.user)
|
||||
# Parse mentions in the rating comment
|
||||
if rating.comment:
|
||||
parse_mentions(rating.comment, self.request.user, rating=rating)
|
||||
|
||||
def get_queryset(self):
|
||||
return Rating.objects.select_related('user', 'flavor', 'flavor__category').prefetch_related('replies', 'replies__user').order_by('-created_at')
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
|
||||
def feed(self, request):
|
||||
# Get ratings from people the user follows
|
||||
followed_users = request.user.following.all()
|
||||
feed_ratings = Rating.objects.filter(user__in=followed_users).select_related('user', 'flavor', 'flavor__category').prefetch_related('replies', 'replies__user').order_by('-created_at')
|
||||
|
||||
paginator = FeedPagination()
|
||||
page = paginator.paginate_queryset(feed_ratings, request)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return paginator.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(feed_ratings, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def check_object_permissions(self, request, obj):
|
||||
super().check_object_permissions(request, obj)
|
||||
if request.method in ['PUT', 'PATCH', 'DELETE'] and obj.user != request.user and not request.user.is_superuser:
|
||||
self.permission_denied(request, message='You cannot edit/delete this rating.')
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
|
||||
def recent(self, request):
|
||||
recent_ratings = Rating.objects.filter(comment__isnull=False).exclude(comment='').select_related('user', 'flavor').order_by('-created_at')[:10]
|
||||
serializer = self.get_serializer(recent_ratings, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def reply(self, request, pk=None):
|
||||
rating = self.get_object()
|
||||
text = request.data.get('text')
|
||||
if not text:
|
||||
return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
reply = Reply.objects.create(user=request.user, rating=rating, text=text)
|
||||
|
||||
# Notify the rating author if it's not the same person
|
||||
if rating.user != request.user:
|
||||
Notification.objects.create(
|
||||
recipient=rating.user,
|
||||
actor=request.user,
|
||||
notification_type='reply',
|
||||
rating=rating,
|
||||
reply=reply
|
||||
)
|
||||
|
||||
# Parse mentions in the reply text
|
||||
parse_mentions(text, request.user, rating=rating, reply=reply)
|
||||
|
||||
return Response(ReplySerializer(reply, context={'request': request}).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
class ReplyViewSet(viewsets.ModelViewSet):
|
||||
queryset = Reply.objects.all()
|
||||
serializer_class = ReplySerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return Reply.objects.select_related('user', 'rating').order_by('created_at')
|
||||
|
||||
def perform_update(self, serializer):
|
||||
reply = self.get_object()
|
||||
if reply.user != self.request.user and not self.request.user.is_superuser:
|
||||
raise permissions.PermissionDenied("You cannot edit this reply.")
|
||||
new_reply = serializer.save()
|
||||
# Parse mentions in the updated text
|
||||
parse_mentions(new_reply.text, self.request.user, rating=new_reply.rating, reply=new_reply)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if instance.user != self.request.user and not self.request.user.is_superuser:
|
||||
raise permissions.PermissionDenied("You cannot delete this reply.")
|
||||
instance.delete()
|
||||
|
||||
class NotificationViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = NotificationSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
pagination_class = None
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.user.notifications.select_related('actor', 'rating__flavor', 'reply__rating__flavor').order_by('-created_at')
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def mark_all_read(self, request):
|
||||
request.user.notifications.filter(is_read=False).update(is_read=True)
|
||||
return Response({'status': 'all read'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def mark_read(self, request, pk=None):
|
||||
notification = self.get_object()
|
||||
notification.is_read = True
|
||||
notification.save()
|
||||
return Response({'status': 'marked read'})
|
||||
|
||||
class UserViewSet(viewsets.ModelViewSet):
|
||||
queryset = User.objects.all()
|
||||
serializer_class = UserSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ['signup', 'verify_signup', 'request_password_reset', 'complete_password_reset']:
|
||||
return [permissions.AllowAny()]
|
||||
return super().get_permissions()
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
|
||||
def following_list(self, request):
|
||||
following = request.user.following.all()
|
||||
serializer = self.get_serializer(following, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
@method_decorator(ratelimit(key='ip', rate='5/h', method='POST', block=True))
|
||||
def signup(self, request):
|
||||
username = request.data.get('username')
|
||||
email = request.data.get('email')
|
||||
password = request.data.get('password')
|
||||
if not username or not email or not password:
|
||||
return Response({'error': 'Username, email and password required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if User.objects.filter(username=username).exists():
|
||||
return Response({'error': 'Username already exists'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if User.objects.filter(email=email).exists():
|
||||
return Response({'error': 'Email already exists'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
import secrets
|
||||
import string
|
||||
code = ''.join(secrets.choice(string.digits) for _ in range(6))
|
||||
|
||||
user = User.objects.create_user(
|
||||
username=username,
|
||||
email=email,
|
||||
password=password,
|
||||
is_active=False,
|
||||
email_confirmation_code=code
|
||||
)
|
||||
|
||||
verification_link = f"{settings.FRONTEND_URL}/verify-email?username={username}&code={code}"
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
'Verify your Holy Flavors account',
|
||||
f'Hi {username},\n\nYour verification code is: {code}\n\nAlternatively, you can complete your registration by clicking the link below:\n{verification_link}\n\nWelcome to the archive!',
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[email],
|
||||
fail_silently=False,
|
||||
)
|
||||
except Exception as e:
|
||||
# If email fails for any reason, delete the user so they can try again
|
||||
user.delete()
|
||||
return Response(
|
||||
{'error': f'Failed to send verification email. Please check your SMTP settings in the .env file. The server error was: {e}'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
return Response({'status': 'User created, please verify your email', 'username': username, 'email': email}, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def resend_verification(self, request):
|
||||
username = request.data.get('username')
|
||||
if not username:
|
||||
return Response({'error': 'Username required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
user = User.objects.get(username=username, is_active=False)
|
||||
except User.DoesNotExist:
|
||||
return Response({'error': 'User not found or already active'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
import secrets
|
||||
import string
|
||||
code = ''.join(secrets.choice(string.digits) for _ in range(6))
|
||||
user.email_confirmation_code = code
|
||||
user.save()
|
||||
|
||||
verification_link = f"{settings.FRONTEND_URL}/verify-email?username={user.username}&code={code}"
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
'Verify your Holy Flavors account',
|
||||
f'Hi {user.username},\n\nYour new verification code is: {code}\n\nAlternatively, you can complete your registration by clicking the link below:\n{verification_link}',
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[user.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{'error': f'Failed to send email: {e}'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
return Response({'status': 'Verification code resent!'})
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def verify_signup(self, request):
|
||||
username = request.data.get('username')
|
||||
code = request.data.get('code')
|
||||
try:
|
||||
user = User.objects.get(username=username, is_active=False)
|
||||
except User.DoesNotExist:
|
||||
return Response({'error': 'User not found or already active'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if user.email_confirmation_code == code:
|
||||
user.is_active = True
|
||||
user.email_confirmation_code = None
|
||||
user.save()
|
||||
return Response({'status': 'Account verified successfully!'})
|
||||
else:
|
||||
return Response({'error': 'Invalid verification code'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny], url_path='profile/(?P<username>[^/.]+)')
|
||||
def public_profile(self, request, username=None):
|
||||
try:
|
||||
user = User.objects.get(username=username)
|
||||
except User.DoesNotExist:
|
||||
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
ratings = user.ratings.select_related('flavor', 'flavor__category').order_by('-score')
|
||||
comments = user.profile_comments.select_related('author').order_by('-created_at')
|
||||
|
||||
followers = user.followers.all()
|
||||
following = user.following.all()
|
||||
|
||||
return Response({
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'theme': user.theme,
|
||||
'avatar': request.build_absolute_uri(user.avatar.url) if user.avatar else None,
|
||||
'following_count': following.count(),
|
||||
'followers_count': followers.count(),
|
||||
'is_following': request.user.following.filter(pk=user.pk).exists() if request.user.is_authenticated else False,
|
||||
'ratings': RatingSerializer(ratings, many=True, context={'request': request}).data,
|
||||
'comments': ProfileCommentSerializer(comments, many=True, context={'request': request}).data,
|
||||
'followers': UserSerializer(followers, many=True, context={'request': request}).data,
|
||||
'following': UserSerializer(following, many=True, context={'request': request}).data
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def add_comment(self, request, pk=None):
|
||||
profile_owner = self.get_object()
|
||||
text = request.data.get('text')
|
||||
if not text:
|
||||
return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
comment = ProfileComment.objects.create(
|
||||
profile_owner=profile_owner,
|
||||
author=request.user,
|
||||
text=text
|
||||
)
|
||||
|
||||
# Notify profile owner
|
||||
if profile_owner != request.user:
|
||||
Notification.objects.create(
|
||||
recipient=profile_owner,
|
||||
actor=request.user,
|
||||
notification_type='profile_comment',
|
||||
profile_comment=comment
|
||||
)
|
||||
|
||||
return Response(ProfileCommentSerializer(comment, context={'request': request}).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['delete'], url_path='delete_comment/(?P<comment_id>[^/.]+)', permission_classes=[permissions.IsAuthenticated])
|
||||
def delete_comment(self, request, pk=None, comment_id=None):
|
||||
profile_owner = self.get_object()
|
||||
try:
|
||||
comment = ProfileComment.objects.get(pk=comment_id, profile_owner=profile_owner)
|
||||
except ProfileComment.DoesNotExist:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if comment.author != request.user and not request.user.is_superuser and profile_owner != request.user:
|
||||
return Response(status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
comment.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def me(self, request):
|
||||
log_user_ip(request.user, request)
|
||||
serializer = self.get_serializer(request.user)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['patch'])
|
||||
def update_preferences(self, request):
|
||||
user = request.user
|
||||
theme = request.data.get('theme')
|
||||
language = request.data.get('language')
|
||||
banner_id = request.data.get('selected_banner')
|
||||
|
||||
updated = False
|
||||
if theme in dict(User.THEME_CHOICES):
|
||||
user.theme = theme
|
||||
updated = True
|
||||
if language in dict(User.LANGUAGE_CHOICES):
|
||||
user.language = language
|
||||
updated = True
|
||||
|
||||
if banner_id is not None:
|
||||
if banner_id == "":
|
||||
user.selected_banner = None
|
||||
updated = True
|
||||
else:
|
||||
try:
|
||||
banner = Banner.objects.get(pk=banner_id, is_enabled=True)
|
||||
user.selected_banner = banner
|
||||
updated = True
|
||||
except Banner.DoesNotExist:
|
||||
return Response({'error': 'Invalid or disabled banner'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if updated:
|
||||
user.save()
|
||||
return Response({
|
||||
'status': 'preferences updated',
|
||||
'theme': user.theme,
|
||||
'language': user.language,
|
||||
'selected_banner': user.selected_banner.id if user.selected_banner else None
|
||||
})
|
||||
return Response({'error': 'No valid updates provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def update_avatar(self, request):
|
||||
user = request.user
|
||||
avatar = request.FILES.get('avatar')
|
||||
if not avatar:
|
||||
return Response({'error': 'No avatar provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Check size (2MB = 2 * 1024 * 1024 bytes)
|
||||
if avatar.size > 2 * 1024 * 1024:
|
||||
return Response({'error': 'File size exceeds 2MB limit'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user.avatar = avatar
|
||||
user.save()
|
||||
serializer = self.get_serializer(user)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def change_password(self, request):
|
||||
user = request.user
|
||||
old_password = request.data.get('old_password')
|
||||
new_password = request.data.get('new_password')
|
||||
if not user.check_password(old_password):
|
||||
return Response({'error': 'Wrong old password'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
return Response({'status': 'password changed'})
|
||||
|
||||
@action(detail=False, methods=['patch'])
|
||||
def update_profile(self, request):
|
||||
user = request.user
|
||||
username = request.data.get('username')
|
||||
email = request.data.get('email')
|
||||
|
||||
message = ""
|
||||
if username and username != user.username:
|
||||
if User.objects.filter(username=username).exists():
|
||||
return Response({'error': 'Username already taken'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
user.username = username
|
||||
|
||||
if email and email != user.email:
|
||||
import secrets
|
||||
import string
|
||||
code = ''.join(secrets.choice(string.digits) for _ in range(6))
|
||||
user.pending_email = email
|
||||
user.email_confirmation_code = code
|
||||
send_mail(
|
||||
'Confirm your new email',
|
||||
f'Hi {user.username},\n\nYour confirmation code is: {code}',
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[email],
|
||||
fail_silently=False,
|
||||
)
|
||||
message = " Confirmation code sent to new email."
|
||||
|
||||
user.save()
|
||||
serializer = self.get_serializer(user)
|
||||
data = serializer.data
|
||||
if message:
|
||||
data['message'] = message
|
||||
return Response(data)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def follow(self, request, pk=None):
|
||||
user_to_follow = self.get_object()
|
||||
if user_to_follow == request.user:
|
||||
return Response({'error': 'You cannot follow yourself'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not request.user.following.filter(pk=user_to_follow.pk).exists():
|
||||
request.user.following.add(user_to_follow)
|
||||
# Notify the user being followed
|
||||
Notification.objects.create(
|
||||
recipient=user_to_follow,
|
||||
actor=request.user,
|
||||
notification_type='follow'
|
||||
)
|
||||
|
||||
return Response({'status': 'followed'})
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def unfollow(self, request, pk=None):
|
||||
user_to_unfollow = self.get_object()
|
||||
request.user.following.remove(user_to_unfollow)
|
||||
return Response({'status': 'unfollowed'})
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def confirm_email(self, request):
|
||||
user = request.user
|
||||
code = request.data.get('code')
|
||||
if not user.email_confirmation_code or code != user.email_confirmation_code:
|
||||
return Response({'error': 'Invalid or expired code'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user.email = user.pending_email
|
||||
user.pending_email = None
|
||||
user.email_confirmation_code = None
|
||||
user.save()
|
||||
return Response({'status': 'Email confirmed', 'email': user.email})
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
@method_decorator(ratelimit(key='ip', rate='3/h', method='POST', block=True))
|
||||
def request_password_reset(self, request):
|
||||
email = request.data.get('email')
|
||||
try:
|
||||
user = User.objects.get(email=email, is_active=True)
|
||||
except User.DoesNotExist:
|
||||
return Response({'status': 'If an account exists with this email, a reset code has been sent.'})
|
||||
|
||||
import secrets
|
||||
import string
|
||||
code = ''.join(secrets.choice(string.digits) for _ in range(6))
|
||||
user.email_confirmation_code = code
|
||||
user.save()
|
||||
|
||||
send_mail(
|
||||
'Password Reset Request',
|
||||
f'Hi {user.username},\n\nYour password reset code is: {code}',
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[email],
|
||||
fail_silently=False,
|
||||
)
|
||||
return Response({'status': 'If an account exists with this email, a reset code has been sent.'})
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
@method_decorator(ratelimit(key='ip', rate='5/h', method='POST', block=True))
|
||||
def complete_password_reset(self, request):
|
||||
email = request.data.get('email')
|
||||
code = request.data.get('code')
|
||||
new_password = request.data.get('password')
|
||||
|
||||
try:
|
||||
user = User.objects.get(email=email, email_confirmation_code=code)
|
||||
except User.DoesNotExist:
|
||||
return Response({'error': 'Invalid email or code'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user.set_password(new_password)
|
||||
user.email_confirmation_code = None
|
||||
user.save()
|
||||
return Response({'status': 'Password reset successful!'})
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
@method_decorator(ratelimit(key='user', rate='3/h', method='POST', block=True))
|
||||
def request_account_deletion(self, request):
|
||||
import secrets
|
||||
import string
|
||||
code = ''.join(secrets.choice(string.digits) for _ in range(6))
|
||||
|
||||
user = request.user
|
||||
user.email_confirmation_code = code
|
||||
user.save()
|
||||
|
||||
send_mail(
|
||||
'Confirm Account Deletion - Holy Flavors Archive',
|
||||
f'Hi {user.username},\n\nYou requested to delete your account. This action is permanent.\n\nYour deletion code is: {code}',
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[user.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
return Response({'status': 'Code sent to your email'})
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def confirm_account_deletion(self, request):
|
||||
user = request.user
|
||||
code = request.data.get('code')
|
||||
if not user.email_confirmation_code or code != user.email_confirmation_code:
|
||||
return Response({'error': 'Invalid or expired code'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user.delete()
|
||||
return Response({'status': 'Account deleted'})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def dashboard(self, request):
|
||||
user = request.user
|
||||
rated_ids = user.ratings.values_list('flavor_id', flat=True)
|
||||
followed_users = user.following.all()
|
||||
|
||||
# Missing flavors with community avg and followed circle avg
|
||||
missing_flavors = Flavor.objects.exclude(id__in=rated_ids) \
|
||||
.exclude(category__name='Packs and other') \
|
||||
.select_related('category') \
|
||||
.annotate(
|
||||
average_rating=Avg('ratings__score'),
|
||||
followed_average_rating=Avg('ratings__score', filter=Q(ratings__user__in=followed_users))
|
||||
).order_by('category__name', 'name')
|
||||
|
||||
rated_flavors = user.ratings.select_related('flavor', 'flavor__category') \
|
||||
.prefetch_related('replies', 'replies__user') \
|
||||
.order_by('-created_at')
|
||||
|
||||
return Response({
|
||||
'user': UserSerializer(user).data,
|
||||
'rated_count': rated_flavors.count(),
|
||||
'missing_count': missing_flavors.count(),
|
||||
'missing_flavors': FlavorSerializer(missing_flavors, many=True, context={'request': request}).data,
|
||||
'my_ratings': RatingSerializer(rated_flavors, many=True, context={'request': request}).data
|
||||
})
|
||||
class TicketViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = TicketSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_superuser:
|
||||
return Ticket.objects.all().prefetch_related('messages', 'messages__user').order_by('-updated_at')
|
||||
return Ticket.objects.filter(user=self.request.user).prefetch_related('messages', 'messages__user').order_by('-updated_at')
|
||||
|
||||
def perform_create(self, serializer):
|
||||
ticket = serializer.save(user=self.request.user)
|
||||
# Notify all superusers about new ticket
|
||||
admins = User.objects.filter(is_superuser=True).exclude(pk=self.request.user.pk)
|
||||
for admin in admins:
|
||||
Notification.objects.create(
|
||||
recipient=admin,
|
||||
actor=self.request.user,
|
||||
notification_type='ticket_new',
|
||||
ticket=ticket
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def add_message(self, request, pk=None):
|
||||
ticket = self.get_object()
|
||||
text = request.data.get('text')
|
||||
if not text:
|
||||
return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
msg = TicketMessage.objects.create(ticket=ticket, user=request.user, text=text)
|
||||
|
||||
# Notification logic
|
||||
if request.user.is_superuser:
|
||||
# Notify the user who opened the ticket
|
||||
if ticket.user != request.user:
|
||||
Notification.objects.create(
|
||||
recipient=ticket.user,
|
||||
actor=request.user,
|
||||
notification_type='ticket_reply',
|
||||
ticket=ticket
|
||||
)
|
||||
else:
|
||||
# Notify all superusers
|
||||
admins = User.objects.filter(is_superuser=True).exclude(pk=request.user.pk)
|
||||
for admin in admins:
|
||||
Notification.objects.create(
|
||||
recipient=admin,
|
||||
actor=request.user,
|
||||
notification_type='ticket_reply',
|
||||
ticket=ticket
|
||||
)
|
||||
|
||||
return Response(TicketMessageSerializer(msg).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAdminUser])
|
||||
def update_status(self, request, pk=None):
|
||||
ticket = self.get_object()
|
||||
new_status = request.data.get('status')
|
||||
if new_status in dict(Ticket.STATUS_CHOICES):
|
||||
ticket.status = new_status
|
||||
ticket.save()
|
||||
return Response({'status': 'updated'})
|
||||
return Response({'error': 'Invalid status'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
if not request.user.is_superuser:
|
||||
raise permissions.PermissionDenied("Only admins can delete tickets.")
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
class BannerViewSet(viewsets.ModelViewSet):
|
||||
queryset = Banner.objects.all()
|
||||
serializer_class = BannerSerializer
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
pagination_class = None
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_authenticated and self.request.user.is_superuser:
|
||||
return Banner.objects.all()
|
||||
return Banner.objects.filter(is_enabled=True)
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ['list', 'retrieve', 'active']:
|
||||
return [permissions.AllowAny()]
|
||||
return [permissions.IsAdminUser()]
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def active(self, request):
|
||||
# Determine which banner to show
|
||||
# Priority:
|
||||
# 1. Requested user's preference (if username provided in query)
|
||||
# 2. Logged in user's preference
|
||||
# 3. Global default
|
||||
|
||||
username = request.query_params.get('username')
|
||||
user = None
|
||||
if username:
|
||||
try:
|
||||
user = User.objects.get(username=username)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
|
||||
if not user and request.user.is_authenticated:
|
||||
user = request.user
|
||||
|
||||
if user and user.selected_banner and user.selected_banner.is_enabled:
|
||||
serializer = self.get_serializer(user.selected_banner)
|
||||
return Response(serializer.data)
|
||||
|
||||
active_banner = Banner.objects.filter(is_active=True).first()
|
||||
if active_banner:
|
||||
serializer = self.get_serializer(active_banner)
|
||||
return Response(serializer.data)
|
||||
return Response(None)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def activate(self, request, pk=None):
|
||||
banner = self.get_object()
|
||||
banner.is_active = True
|
||||
banner.is_enabled = True # Active banner must be enabled
|
||||
banner.save()
|
||||
return Response({'status': 'banner set as global default'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def toggle_enabled(self, request, pk=None):
|
||||
banner = self.get_object()
|
||||
if banner.is_active:
|
||||
return Response({'error': 'Cannot disable the global default banner.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
banner.is_enabled = not banner.is_enabled
|
||||
banner.save()
|
||||
return Response({'status': 'enabled' if banner.is_enabled else 'disabled'})
|
||||
|
||||
class AdminViewSet(viewsets.ViewSet):
|
||||
permission_classes = [permissions.IsAdminUser]
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def stats(self, request):
|
||||
return Response({
|
||||
'total_users': User.objects.count(),
|
||||
'total_ratings': Rating.objects.count(),
|
||||
'total_replies': Reply.objects.count(),
|
||||
'open_tickets': Ticket.objects.filter(status='open').count(),
|
||||
'email_config': {
|
||||
'host': getattr(settings, 'EMAIL_HOST', 'None'),
|
||||
'port': getattr(settings, 'EMAIL_PORT', 'None'),
|
||||
'use_tls': getattr(settings, 'EMAIL_USE_TLS', False),
|
||||
'use_ssl': getattr(settings, 'EMAIL_USE_SSL', False),
|
||||
'skip_verify': getattr(settings, 'EMAIL_SKIP_CERT_VERIFICATION', False),
|
||||
},
|
||||
'server_info': {
|
||||
'debug': settings.DEBUG,
|
||||
'allowed_hosts': settings.ALLOWED_HOSTS,
|
||||
'frontend_url': getattr(settings, 'FRONTEND_URL', 'Not set'),
|
||||
'media_root': str(settings.MEDIA_ROOT),
|
||||
'static_root': str(settings.STATIC_ROOT),
|
||||
}
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['get', 'patch'])
|
||||
def config(self, request):
|
||||
config = SystemConfig.get_solo()
|
||||
if request.method == 'PATCH':
|
||||
serializer = SystemConfigSerializer(config, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
|
||||
serializer = SystemConfigSerializer(config)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def jobs(self, request):
|
||||
# Ensure all jobs exist
|
||||
for job_type, label in Job.JOB_TYPES:
|
||||
Job.objects.get_or_create(name=job_type)
|
||||
|
||||
jobs = Job.objects.all().order_by('name')
|
||||
serializer = JobSerializer(jobs, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def trigger_job(self, request, pk=None):
|
||||
try:
|
||||
job = Job.objects.get(pk=pk)
|
||||
job.status = 'pending'
|
||||
job.save()
|
||||
return Response({'status': 'Job queued'})
|
||||
except Job.DoesNotExist:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@action(detail=True, methods=['patch'])
|
||||
def update_job_schedule(self, request, pk=None):
|
||||
try:
|
||||
job = Job.objects.get(pk=pk)
|
||||
interval = request.data.get('interval_hours')
|
||||
next_run = request.data.get('next_run')
|
||||
|
||||
if interval is not None:
|
||||
job.interval_hours = int(interval)
|
||||
|
||||
if next_run is not None:
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from django.utils import timezone
|
||||
parsed_dt = parse_datetime(next_run)
|
||||
if parsed_dt:
|
||||
# If it's naive (has no timezone info), assume it's local time
|
||||
if timezone.is_naive(parsed_dt):
|
||||
job.next_run = timezone.make_aware(parsed_dt, timezone.get_current_timezone())
|
||||
else:
|
||||
job.next_run = parsed_dt
|
||||
elif interval is not None and job.interval_hours > 0 and not job.next_run:
|
||||
from django.utils import timezone
|
||||
job.next_run = timezone.now()
|
||||
|
||||
job.save()
|
||||
return Response(JobSerializer(job).data)
|
||||
except Job.DoesNotExist:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def send_test_email(self, request):
|
||||
try:
|
||||
send_mail(
|
||||
'Holy Flavors Admin Test Email',
|
||||
f'This is a test email sent to {request.user.email} from the Holy Flavors Admin Interface.',
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[request.user.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
return Response({'status': 'Test email sent!'})
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def users(self, request):
|
||||
users = User.objects.all().prefetch_related('ips').order_by('-date_joined')
|
||||
return Response(AdminUserListSerializer(users, many=True).data)
|
||||
|
||||
@action(detail=True, methods=['get', 'patch', 'delete'])
|
||||
def user_detail(self, request, pk=None):
|
||||
try:
|
||||
user = User.objects.get(pk=pk)
|
||||
except User.DoesNotExist:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if request.method == 'GET':
|
||||
return Response(AdminUserDetailSerializer(user, context={'request': request}).data)
|
||||
elif request.method == 'PATCH':
|
||||
is_active = request.data.get('is_active')
|
||||
if is_active is not None:
|
||||
user.is_active = is_active
|
||||
user.save()
|
||||
return Response(AdminUserDetailSerializer(user, context={'request': request}).data)
|
||||
elif request.method == 'DELETE':
|
||||
user.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,20 @@
|
||||
from api.views._pagination import FeedPagination
|
||||
from api.views.admin import AdminViewSet
|
||||
from api.views.banner import BannerViewSet
|
||||
from api.views.flavor import CategoryViewSet, FlavorViewSet
|
||||
from api.views.rating import RatingViewSet, ReplyViewSet
|
||||
from api.views.support import TicketViewSet
|
||||
from api.views.user import NotificationViewSet, UserViewSet
|
||||
|
||||
__all__ = [
|
||||
"AdminViewSet",
|
||||
"BannerViewSet",
|
||||
"CategoryViewSet",
|
||||
"FeedPagination",
|
||||
"FlavorViewSet",
|
||||
"NotificationViewSet",
|
||||
"RatingViewSet",
|
||||
"ReplyViewSet",
|
||||
"TicketViewSet",
|
||||
"UserViewSet",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
|
||||
class FeedPagination(PageNumberPagination):
|
||||
page_size = 10
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,65 @@
|
||||
from rest_framework import permissions, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from api.models import Banner, User
|
||||
from api.serializers import BannerSerializer
|
||||
|
||||
|
||||
class BannerViewSet(viewsets.ModelViewSet):
|
||||
queryset = Banner.objects.all()
|
||||
serializer_class = BannerSerializer
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
pagination_class = None
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_authenticated and self.request.user.is_superuser:
|
||||
return Banner.objects.all()
|
||||
return Banner.objects.filter(is_enabled=True)
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ["list", "retrieve", "active"]:
|
||||
return [permissions.AllowAny()]
|
||||
return [permissions.IsAdminUser()]
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def active(self, request: Request) -> Response:
|
||||
username = request.query_params.get("username")
|
||||
user = None
|
||||
if username:
|
||||
try:
|
||||
user = User.objects.get(username=username)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
|
||||
if not user and request.user.is_authenticated:
|
||||
user = request.user
|
||||
|
||||
if user and user.selected_banner and user.selected_banner.is_enabled:
|
||||
return Response(self.get_serializer(user.selected_banner).data)
|
||||
|
||||
active_banner = Banner.objects.filter(is_active=True).first()
|
||||
if active_banner:
|
||||
return Response(self.get_serializer(active_banner).data)
|
||||
return Response(None)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def activate(self, request: Request, pk=None) -> Response:
|
||||
banner = self.get_object()
|
||||
banner.is_active = True
|
||||
banner.is_enabled = True
|
||||
banner.save()
|
||||
return Response({"status": "banner set as global default"})
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def toggle_enabled(self, request: Request, pk=None) -> Response:
|
||||
banner = self.get_object()
|
||||
if banner.is_active:
|
||||
return Response(
|
||||
{"error": "Cannot disable the global default banner."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
banner.is_enabled = not banner.is_enabled
|
||||
banner.save()
|
||||
return Response({"status": "enabled" if banner.is_enabled else "disabled"})
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
from holy_backend.celery import app as celery_app
|
||||
|
||||
__all__ = ("celery_app",)
|
||||
@@ -11,6 +11,6 @@ import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'holy_backend.settings')
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Celery application for holy_backend.
|
||||
|
||||
Autodiscovers tasks from installed Django apps. Broker/backend configuration
|
||||
is read from Django settings (CELERY_* namespace).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
|
||||
|
||||
app = Celery("holy_backend")
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
app.autodiscover_tasks()
|
||||
@@ -1,231 +0,0 @@
|
||||
"""
|
||||
Django settings for holy_backend project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 6.0.2.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/6.0/ref/settings/
|
||||
"""
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-zos99ue2a89!4jrv4g@_b4nn4z!5o*@8k!5cr9!u$j%_@xb=l8')
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = os.environ.get('DEBUG', 'false').lower() == 'true'
|
||||
|
||||
# Simplification: Use a single DOMAIN variable to derive multiple settings
|
||||
DOMAIN = os.environ.get('DOMAIN', 'localhost')
|
||||
|
||||
# Helper to build lists from env or defaults
|
||||
def get_env_list(var_name, default_list):
|
||||
val = os.environ.get(var_name)
|
||||
if val:
|
||||
return [item.strip() for item in val.split(',')]
|
||||
return default_list
|
||||
|
||||
ALLOWED_HOSTS = get_env_list('ALLOWED_HOSTS', ['localhost', '127.0.0.1', 'backend', 'worker', DOMAIN])
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'whitenoise.runserver_nostatic',
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
# Third party
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
'corsheaders',
|
||||
'django_filters',
|
||||
'drf_spectacular',
|
||||
'django_ratelimit',
|
||||
# Local
|
||||
'api',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'compression_middleware.middleware.CompressionMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'holy_backend.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'holy_backend.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
}
|
||||
}
|
||||
|
||||
# Cache configuration for Ratelimiting
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.db.DatabaseCache',
|
||||
'LOCATION': 'django_ratelimit_cache',
|
||||
}
|
||||
}
|
||||
|
||||
# Silence ratelimit warnings for non-Redis/Memcached backends
|
||||
SILENCED_SYSTEM_CHECKS = ['django_ratelimit.W001', 'django_ratelimit.E003']
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
STATICFILES_STORAGE = 'whitenoise.storage.CompressedStaticFilesStorage'
|
||||
WHITENOISE_INDEX_PAGE = True
|
||||
|
||||
MEDIA_URL = 'media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
# --- Reverse Proxy Configuration ---
|
||||
# These settings are crucial for running behind a proxy like Traefik
|
||||
USE_X_FORWARDED_HOST = True
|
||||
USE_X_FORWARDED_PORT = True
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
|
||||
# Production Security
|
||||
if not DEBUG:
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
SECURE_HSTS_SECONDS = 31536000 # 1 year
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_HSTS_PRELOAD = True
|
||||
|
||||
# Read CSRF trusted origins from environment variable
|
||||
CSRF_TRUSTED_ORIGINS = get_env_list('CSRF_TRUSTED_ORIGINS', [f'https://{DOMAIN}', 'http://localhost:5173'])
|
||||
|
||||
# Custom User Model
|
||||
AUTH_USER_MODEL = 'api.User'
|
||||
|
||||
# REST Framework
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'rest_framework.authentication.TokenAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
],
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
|
||||
],
|
||||
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'],
|
||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||
'PAGE_SIZE': 50,
|
||||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||
}
|
||||
|
||||
# CORS
|
||||
CORS_ALLOWED_ORIGINS = get_env_list('CORS_ALLOWED_ORIGINS', [f'https://{DOMAIN}', 'http://localhost:5173', 'http://127.0.0.1:5173'])
|
||||
|
||||
FRONTEND_URL = os.environ.get('FRONTEND_URL', f'https://{DOMAIN}').rstrip('/')
|
||||
|
||||
import os
|
||||
# ... (near top of file)
|
||||
|
||||
# Email (SMTP)
|
||||
# For production, set these via environment variables.
|
||||
# For development, it will default to printing emails to the console.
|
||||
EMAIL_HOST = os.environ.get('EMAIL_HOST')
|
||||
EMAIL_PORT = int(os.environ.get('EMAIL_PORT', 587))
|
||||
# Default to False unless explicitly set to 'true' in the .env file
|
||||
EMAIL_USE_TLS = os.environ.get('EMAIL_USE_TLS', 'false').lower() == 'true'
|
||||
EMAIL_USE_SSL = os.environ.get('EMAIL_USE_SSL', 'false').lower() == 'true'
|
||||
EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER')
|
||||
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD')
|
||||
EMAIL_SKIP_CERT_VERIFICATION = os.environ.get('EMAIL_SKIP_CERT_VERIFICATION', 'false').lower() == 'true'
|
||||
DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', EMAIL_HOST_USER)
|
||||
SERVER_EMAIL = DEFAULT_FROM_EMAIL
|
||||
|
||||
if EMAIL_HOST:
|
||||
EMAIL_BACKEND = 'api.email_backend.InsecureEmailBackend'
|
||||
else:
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
|
||||
# Swagger
|
||||
SPECTACULAR_SETTINGS = {
|
||||
'TITLE': 'Holy Flavors API',
|
||||
'DESCRIPTION': 'API for rating flavors',
|
||||
'VERSION': '1.0.0',
|
||||
'SERVE_INCLUDE_SCHEMA': False,
|
||||
}
|
||||
Whitespace-only changes.
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Development settings. Selected by default via manage.py / wsgi.py / asgi.py."""
|
||||
|
||||
import os
|
||||
|
||||
from holy_backend.settings.base import * # noqa: F401,F403
|
||||
|
||||
DEBUG = True
|
||||
|
||||
# Dev-only fallback. Override via SECRET_KEY env var if you want consistency
|
||||
# across restarts (sessions invalidate when this changes).
|
||||
SECRET_KEY = os.environ.get(
|
||||
"SECRET_KEY",
|
||||
"django-insecure-dev-only-do-not-use-in-production",
|
||||
)
|
||||
|
||||
# Run Celery tasks synchronously in dev/tests unless a broker is available.
|
||||
CELERY_TASK_ALWAYS_EAGER = os.environ.get("CELERY_TASK_ALWAYS_EAGER", "true").lower() == "true"
|
||||
CELERY_TASK_EAGER_PROPAGATES = True
|
||||
@@ -0,0 +1,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
|
||||
@@ -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}),
|
||||
]
|
||||
@@ -11,6 +11,6 @@ import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'holy_backend.settings')
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
|
||||
|
||||
application = get_wsgi_application()
|
||||
+3
-2
@@ -1,12 +1,13 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'holy_backend.settings')
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
@@ -18,5 +19,5 @@ def main():
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,49 @@
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
extend-exclude = ["migrations", "venv", ".venv"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"B", # flake8-bugbear
|
||||
"UP", # pyupgrade
|
||||
"C4", # flake8-comprehensions
|
||||
"DJ", # flake8-django
|
||||
]
|
||||
ignore = [
|
||||
"E501", # line too long (formatter handles it)
|
||||
"B008", # function call in default argument (common in DRF)
|
||||
"DJ001", # null=True on string fields (pre-existing schema decisions)
|
||||
"DJ008", # missing __str__ (not all models need one)
|
||||
"DJ012", # model attribute ordering style
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"api/migrations/*.py" = ["ALL"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"]
|
||||
exclude = ["migrations/", "venv/", ".venv/"]
|
||||
disallow_untyped_defs = false
|
||||
check_untyped_defs = true
|
||||
warn_unused_ignores = true
|
||||
warn_redundant_casts = true
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.django-stubs]
|
||||
django_settings_module = "holy_backend.settings.dev"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = "holy_backend.settings.dev"
|
||||
python_files = ["test_*.py", "tests.py"]
|
||||
testpaths = ["api/tests"]
|
||||
addopts = "-ra --strict-markers"
|
||||
@@ -0,0 +1,14 @@
|
||||
-r requirements.txt
|
||||
|
||||
# Linting / formatting
|
||||
ruff>=0.6.0
|
||||
|
||||
# Type checking
|
||||
mypy>=1.11.0
|
||||
django-stubs[compatible-mypy]>=5.0.0
|
||||
djangorestframework-stubs[compatible-mypy]>=3.15.0
|
||||
|
||||
# Testing
|
||||
pytest>=8.0.0
|
||||
pytest-django>=4.8.0
|
||||
pytest-cov>=5.0.0
|
||||
@@ -10,3 +10,7 @@ gunicorn>=21.2.0
|
||||
django-ratelimit>=4.1.0
|
||||
django-compression-middleware>=0.5.0
|
||||
requests>=2.31.0
|
||||
celery[redis]>=5.4.0
|
||||
django-celery-beat>=2.6.0
|
||||
redis>=5.0.0
|
||||
djangorestframework-simplejwt>=5.3.1
|
||||
+2987
File diff suppressed because it is too large.
Load diff
+93
-3
@@ -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:
|
||||
@@ -0,0 +1,5 @@
|
||||
dist
|
||||
node_modules
|
||||
public
|
||||
*.lock
|
||||
package-lock.json
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
@@ -19,5 +19,18 @@ export default defineConfig([
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-empty-object-type': 'warn',
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
'react-hooks/immutability': 'warn',
|
||||
'react-hooks/purity': 'warn',
|
||||
'react-refresh/only-export-components': 'warn',
|
||||
'no-empty': 'warn',
|
||||
},
|
||||
},
|
||||
])
|
||||
Generated
+1571
-4
File diff suppressed because it is too large.
Load diff
+22
-2
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import axios from 'axios';
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
// Does this cookie string begin with the name we want?
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api/',
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
// Set DRF Auth Token if available from localStorage
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Token ${token}`;
|
||||
}
|
||||
|
||||
// Set CSRF token for state-changing methods
|
||||
const csrfToken = getCookie('csrftoken');
|
||||
const method = config.method?.toUpperCase();
|
||||
if (csrfToken && method && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
||||
if (config.headers) {
|
||||
config.headers['X-CSRFToken'] = csrfToken;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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
Reference in new issue
Block a user