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
205 files changed
+27375
-10782
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.
|
# Your primary domain (e.g., example.com). Used to derive ALLOWED_HOSTS, CORS, and CSRF settings.
|
||||||
DOMAIN="localhost"
|
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"
|
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
|
# Set to "false" in production for security and performance
|
||||||
DEBUG=true
|
DEBUG=true
|
||||||
|
|
||||||
# Custom URL for the Django Admin interface (e.g., my-secret-admin/)
|
# Custom URL for the Django Admin interface (e.g., my-secret-admin/)
|
||||||
ADMIN_URL="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) ---
|
# --- Advanced Networking (Optional, derived from DOMAIN if unset) ---
|
||||||
# ALLOWED_HOSTS=example.com,another-domain.com
|
# ALLOWED_HOSTS=example.com,another-domain.com
|
||||||
# CSRF_TRUSTED_ORIGINS=https://example.com
|
# CSRF_TRUSTED_ORIGINS=https://example.com
|
||||||
|
|||||||
+42
-16
@@ -8,23 +8,40 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
frontend-build:
|
frontend-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '25'
|
node-version: '25'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
working-directory: frontend
|
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
|
run: npm run build
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
@@ -32,6 +49,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
env:
|
||||||
|
SECRET_KEY: "ci-test-key"
|
||||||
|
DEBUG: "false"
|
||||||
|
ALLOWED_HOSTS: "localhost"
|
||||||
|
DJANGO_SETTINGS_MODULE: holy_backend.settings.dev
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -40,24 +62,28 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: '3.14.3'
|
python-version: '3.14.3'
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
cache-dependency-path: backend/requirements.txt
|
cache-dependency-path: backend/requirements-dev.txt
|
||||||
|
|
||||||
- name: Install Dependencies
|
- 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
|
working-directory: backend
|
||||||
|
|
||||||
- name: Django System Check
|
- name: Django System Check
|
||||||
run: python manage.py check
|
run: python manage.py check
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
env:
|
|
||||||
SECRET_KEY: "ci-test-key"
|
|
||||||
DEBUG: "false"
|
|
||||||
ALLOWED_HOSTS: "localhost"
|
|
||||||
|
|
||||||
- name: Check Migrations
|
- name: Check Migrations
|
||||||
run: python manage.py makemigrations --check --dry-run
|
run: python manage.py makemigrations --check --dry-run
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
env:
|
|
||||||
SECRET_KEY: "ci-test-key"
|
- name: Run Tests
|
||||||
DEBUG: "false"
|
run: pytest
|
||||||
ALLOWED_HOSTS: "localhost"
|
working-directory: backend
|
||||||
@@ -21,6 +21,7 @@ backend/staticfiles/
|
|||||||
backend/media/
|
backend/media/
|
||||||
backend/django_cache/
|
backend/django_cache/
|
||||||
backend/backups/
|
backend/backups/
|
||||||
|
backend/.seeded
|
||||||
|
|
||||||
# React/Vite build
|
# React/Vite build
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
@@ -32,9 +32,11 @@ The easiest way to get the full stack (Backend + Frontend + DB) running behind a
|
|||||||
```
|
```
|
||||||
3. **Start Services:**
|
3. **Start Services:**
|
||||||
```bash
|
```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)
|
### 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 migrate
|
||||||
python manage.py sync_flavors
|
python manage.py sync_flavors
|
||||||
python manage.py seed_legacy_flavors
|
python manage.py seed_legacy_flavors
|
||||||
|
python manage.py seed_banners
|
||||||
```
|
```
|
||||||
3. **Run Server:**
|
3. **Run Server:**
|
||||||
```bash
|
```bash
|
||||||
python manage.py runserver
|
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
|
#### Frontend
|
||||||
1. **Navigate and Install:**
|
1. **Navigate and Install:**
|
||||||
@@ -76,9 +80,10 @@ The Holy Flavor Rater uses a modern **decoupled architecture**:
|
|||||||
|
|
||||||
### Backend (Django REST Framework)
|
### Backend (Django REST Framework)
|
||||||
- **Models:** Custom User model, Flavors (with category constraints), Ratings (with mention parsing), and Dynamic Banners.
|
- **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.
|
- **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.
|
||||||
- **Security:** Token-based authentication, CSRF protection, and email-verified account activation.
|
- **Caching & rate-limiting:** Django cache uses Redis (`RedisCache` on DB 1). `django_ratelimit` consumes the same cache.
|
||||||
- **Monitoring:** Integrated `/health/` endpoint for Docker container status.
|
- **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)
|
### Frontend (React 19 + TypeScript)
|
||||||
- **State Management:** React Hooks and local state for high performance.
|
- **State Management:** React Hooks and local state for high performance.
|
||||||
@@ -101,10 +106,12 @@ bash scripts/install-hooks.sh
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Management Commands
|
### 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 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 cleanup_duplicates`: Merges duplicate entries and maintains rating integrity.
|
||||||
- `python manage.py seed_banners`: Updates procedurally generated banner configurations from JSON.
|
- `python manage.py seed_legacy_flavors`: Loads retired flavors from `legacy/*.json`.
|
||||||
- `python manage.py backup_db`: Creates a consistent SQLite snapshot.
|
- `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:
|
Ensure your `.env` contains:
|
||||||
- `DEBUG=false`
|
- `DEBUG=false`
|
||||||
|
- `DJANGO_SETTINGS_MODULE=holy_backend.settings.prod`
|
||||||
- `SECRET_KEY`: A long random string.
|
- `SECRET_KEY`: A long random string.
|
||||||
- `ALLOWED_HOSTS`: Your domain (e.g., `holy.narl.io`).
|
- `ALLOWED_HOSTS`: Your domain (e.g., `holy.narl.io`).
|
||||||
- `FRONTEND_URL`: For one-click email verification links.
|
- `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
|
## 📄 License
|
||||||
MIT
|
MIT
|
||||||
@@ -13,6 +13,13 @@ COPY requirements.txt .
|
|||||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
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 the rest of the backend code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
+35
-8
@@ -1,17 +1,44 @@
|
|||||||
|
from django import forms
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth.admin import UserAdmin
|
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):
|
class CustomUserAdmin(UserAdmin):
|
||||||
fieldsets = UserAdmin.fieldsets + (
|
fieldsets = UserAdmin.fieldsets + ((None, {"fields": ("theme",)}),)
|
||||||
(None, {'fields': ('theme',)}),
|
add_fieldsets = UserAdmin.add_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(User, CustomUserAdmin)
|
||||||
admin.site.register(Category)
|
admin.site.register(Category)
|
||||||
admin.site.register(Flavor)
|
admin.site.register(Flavor, FlavorAdmin)
|
||||||
admin.site.register(Rating)
|
admin.site.register(Rating)
|
||||||
admin.site.register(Banner)
|
admin.site.register(Banner)
|
||||||
+1
-1
@@ -2,4 +2,4 @@ from django.apps import AppConfig
|
|||||||
|
|
||||||
|
|
||||||
class ApiConfig(AppConfig):
|
class ApiConfig(AppConfig):
|
||||||
name = 'api'
|
name = "api"
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import ssl
|
import ssl
|
||||||
from django.core.mail.backends.smtp import EmailBackend
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.mail.backends.smtp import EmailBackend
|
||||||
|
|
||||||
|
|
||||||
class InsecureEmailBackend(EmailBackend):
|
class InsecureEmailBackend(EmailBackend):
|
||||||
"""
|
"""
|
||||||
@@ -8,9 +10,10 @@ class InsecureEmailBackend(EmailBackend):
|
|||||||
Useful for local development or internal SMTP servers with self-signed certificates.
|
Useful for local development or internal SMTP servers with self-signed certificates.
|
||||||
Set EMAIL_SKIP_CERT_VERIFICATION = True in settings to use.
|
Set EMAIL_SKIP_CERT_VERIFICATION = True in settings to use.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ssl_context(self):
|
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
|
# The most robust way to bypass verification for self-signed certs
|
||||||
context = ssl._create_unverified_context()
|
context = ssl._create_unverified_context()
|
||||||
return context
|
return context
|
||||||
|
|||||||
@@ -1,38 +1,43 @@
|
|||||||
import sqlite3
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import sqlite3
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
class Command(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):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument('--output', type=str, help='Specific output path for the backup file')
|
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(
|
||||||
parser.add_argument('--full', action='store_true', help='Create a full archive including media')
|
"--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):
|
def handle(self, *args, **options):
|
||||||
db_conf = settings.DATABASES['default']
|
db_conf = settings.DATABASES["default"]
|
||||||
if db_conf['ENGINE'] != 'django.db.backends.sqlite3':
|
if db_conf["ENGINE"] != "django.db.backends.sqlite3":
|
||||||
self.stdout.write(self.style.ERROR("This command only supports SQLite."))
|
self.stdout.write(self.style.ERROR("This command only supports SQLite."))
|
||||||
return
|
return
|
||||||
|
|
||||||
db_path = str(db_conf['NAME'])
|
db_path = str(db_conf["NAME"])
|
||||||
backup_dir = os.path.join(settings.BASE_DIR, 'backups')
|
backup_dir = os.path.join(settings.BASE_DIR, "backups")
|
||||||
os.makedirs(backup_dir, exist_ok=True)
|
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
|
# 1. Database Backup
|
||||||
if options['output']:
|
if options["output"]:
|
||||||
db_backup_path = options['output']
|
db_backup_path = options["output"]
|
||||||
else:
|
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}...")
|
self.stdout.write(f"Cloning database {db_path}...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
src = sqlite3.connect(db_path)
|
src = sqlite3.connect(db_path)
|
||||||
dst = sqlite3.connect(db_backup_path)
|
dst = sqlite3.connect(db_backup_path)
|
||||||
@@ -46,12 +51,13 @@ class Command(BaseCommand):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# 2. Full Archive (Optional)
|
# 2. Full Archive (Optional)
|
||||||
if options['full']:
|
if options["full"]:
|
||||||
import tarfile
|
import tarfile
|
||||||
|
|
||||||
archive_name = f"full_backup_{timestamp}.tar.gz"
|
archive_name = f"full_backup_{timestamp}.tar.gz"
|
||||||
archive_path = os.path.join(backup_dir, archive_name)
|
archive_path = os.path.join(backup_dir, archive_name)
|
||||||
self.stdout.write(f"Creating full archive at {archive_path}...")
|
self.stdout.write(f"Creating full archive at {archive_path}...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with tarfile.open(archive_path, "w:gz") as tar:
|
with tarfile.open(archive_path, "w:gz") as tar:
|
||||||
# Add database
|
# Add database
|
||||||
@@ -60,19 +66,19 @@ class Command(BaseCommand):
|
|||||||
media_dir = str(settings.MEDIA_ROOT)
|
media_dir = str(settings.MEDIA_ROOT)
|
||||||
if os.path.exists(media_dir):
|
if os.path.exists(media_dir):
|
||||||
tar.add(media_dir, arcname="media")
|
tar.add(media_dir, arcname="media")
|
||||||
|
|
||||||
# Remove the raw sqlite backup after bundling it into the archive
|
# Remove the raw sqlite backup after bundling it into the archive
|
||||||
if not options['output']:
|
if not options["output"]:
|
||||||
os.remove(db_backup_path)
|
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:
|
except Exception as e:
|
||||||
self.stdout.write(self.style.ERROR(f"Archive creation failed: {e}"))
|
self.stdout.write(self.style.ERROR(f"Archive creation failed: {e}"))
|
||||||
|
|
||||||
# 3. Cleanup Old Backups
|
# 3. Cleanup Old Backups
|
||||||
retention_days = options['retention']
|
retention_days = options["retention"]
|
||||||
cutoff = datetime.now() - timedelta(days=retention_days)
|
cutoff = datetime.now() - timedelta(days=retention_days)
|
||||||
|
|
||||||
self.stdout.write(f"Cleaning up backups older than {retention_days} days...")
|
self.stdout.write(f"Cleaning up backups older than {retention_days} days...")
|
||||||
for filename in os.listdir(backup_dir):
|
for filename in os.listdir(backup_dir):
|
||||||
file_path = os.path.join(backup_dir, filename)
|
file_path = os.path.join(backup_dir, filename)
|
||||||
@@ -81,6 +87,10 @@ class Command(BaseCommand):
|
|||||||
if file_time < cutoff:
|
if file_time < cutoff:
|
||||||
try:
|
try:
|
||||||
os.remove(file_path)
|
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:
|
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,34 +1,39 @@
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from api.models import Flavor, Rating
|
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
|
|
||||||
|
from api.models import Flavor, Rating
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
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):
|
def handle(self, *args, **options):
|
||||||
# Find duplicates based on name and category
|
# Find duplicates based on name and category
|
||||||
duplicates = Flavor.objects.values('name', 'category').annotate(
|
duplicates = (
|
||||||
name_count=Count('id')
|
Flavor.objects.values("name", "category")
|
||||||
).filter(name_count__gt=1)
|
.annotate(name_count=Count("id"))
|
||||||
|
.filter(name_count__gt=1)
|
||||||
|
)
|
||||||
|
|
||||||
self.stdout.write(f"Found {duplicates.count()} sets of duplicates.")
|
self.stdout.write(f"Found {duplicates.count()} sets of duplicates.")
|
||||||
|
|
||||||
for dup in duplicates:
|
for dup in duplicates:
|
||||||
name = dup['name']
|
name = dup["name"]
|
||||||
category_id = dup['category']
|
category_id = dup["category"]
|
||||||
|
|
||||||
# Get all flavors in this set, ordered by having an external_id first, then by date
|
# Get all flavors in this set, ordered by having an external_id first, then by date
|
||||||
flavor_set = Flavor.objects.filter(
|
flavor_set = Flavor.objects.filter(name=name, category_id=category_id).order_by(
|
||||||
name=name,
|
"-external_id", "created_at"
|
||||||
category_id=category_id
|
)
|
||||||
).order_by('-external_id', 'created_at')
|
|
||||||
|
|
||||||
# Keep the first one, merge others into it
|
# Keep the first one, merge others into it
|
||||||
keep = flavor_set[0]
|
keep = flavor_set[0]
|
||||||
others = flavor_set[1:]
|
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:
|
for other in others:
|
||||||
# Re-assign ratings
|
# Re-assign ratings
|
||||||
for rating in Rating.objects.filter(flavor=other):
|
for rating in Rating.objects.filter(flavor=other):
|
||||||
@@ -39,7 +44,7 @@ class Command(BaseCommand):
|
|||||||
# User already has a rating for the 'keep' flavor
|
# User already has a rating for the 'keep' flavor
|
||||||
# Delete the duplicate rating instead
|
# Delete the duplicate rating instead
|
||||||
rating.delete()
|
rating.delete()
|
||||||
|
|
||||||
# Delete the duplicate flavor
|
# Delete the duplicate flavor
|
||||||
other.delete()
|
other.delete()
|
||||||
|
|
||||||
|
|||||||
@@ -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,64 +1,75 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from api.models import Banner
|
|
||||||
import os
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from api.models import Banner
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
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):
|
def handle(self, *args, **options):
|
||||||
# Determine the banners directory relative to the project root
|
# Determine the banners directory relative to the project root
|
||||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
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):
|
if not os.path.exists(banners_dir):
|
||||||
self.stdout.write(self.style.WARNING(f"Banners directory not found at {banners_dir}"))
|
self.stdout.write(self.style.WARNING(f"Banners directory not found at {banners_dir}"))
|
||||||
return
|
return
|
||||||
|
|
||||||
for filename in os.listdir(banners_dir):
|
for filename in os.listdir(banners_dir):
|
||||||
if filename.endswith('.json'):
|
if filename.endswith(".json"):
|
||||||
filepath = os.path.join(banners_dir, filename)
|
filepath = os.path.join(banners_dir, filename)
|
||||||
try:
|
try:
|
||||||
with open(filepath, 'r') as f:
|
with open(filepath) as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
|
|
||||||
slug = data.get('slug')
|
slug = data.get("slug")
|
||||||
if not slug:
|
if not slug:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
banner, created = Banner.objects.get_or_create(
|
banner, created = Banner.objects.get_or_create(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
defaults={
|
defaults={
|
||||||
'name': data.get('name', slug),
|
"name": data.get("name", slug),
|
||||||
'description': data.get('description', ''),
|
"description": data.get("description", ""),
|
||||||
'settings': data.get('settings', {})
|
"settings": data.get("settings", {}),
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if not created:
|
if not created:
|
||||||
# Sync missing settings from JSON to DB without overwriting existing keys
|
# Sync missing settings from JSON to DB without overwriting existing keys
|
||||||
db_settings = banner.settings or {}
|
db_settings = banner.settings or {}
|
||||||
json_settings = data.get('settings', {})
|
json_settings = data.get("settings", {})
|
||||||
json_schema = data.get('schema', [])
|
json_schema = data.get("schema", [])
|
||||||
|
|
||||||
updated = False
|
updated = False
|
||||||
# Sync missing settings
|
# Sync missing settings
|
||||||
for key, value in json_settings.items():
|
for key, value in json_settings.items():
|
||||||
if key not in db_settings:
|
if key not in db_settings:
|
||||||
db_settings[key] = value
|
db_settings[key] = value
|
||||||
updated = True
|
updated = True
|
||||||
|
|
||||||
# Always sync schema as it represents the UI structure
|
# Always sync schema as it represents the UI structure
|
||||||
if banner.schema != json_schema:
|
if banner.schema != json_schema:
|
||||||
banner.schema = json_schema
|
banner.schema = json_schema
|
||||||
updated = True
|
updated = True
|
||||||
|
|
||||||
if updated:
|
if updated:
|
||||||
banner.settings = db_settings
|
banner.settings = db_settings
|
||||||
banner.save()
|
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:
|
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:
|
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 json
|
||||||
import os
|
import os
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.core.files import File
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
from api.models import Category, Flavor
|
from api.models import Category, Flavor
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
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):
|
def download_image(self, url, flavor_name):
|
||||||
|
"""Download legacy image into flavors/legacy_<slug>/00_<hash>.<ext>. Returns rel path."""
|
||||||
if not url:
|
if not url:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
safe_name = slugify(flavor_name)
|
safe_name = slugify(flavor_name)
|
||||||
ext = url.split('.')[-1].split('?')[0].lower()
|
path = urlparse(url).path
|
||||||
if ext not in ['jpg', 'jpeg', 'png', 'webp', 'gif']:
|
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
|
||||||
ext = 'png'
|
if ext not in ("jpg", "jpeg", "png", "webp", "gif"):
|
||||||
|
ext = "png"
|
||||||
filename = f"legacy_{safe_name}.{ext}"
|
url_hash = hashlib.sha1(path.encode("utf-8")).hexdigest()[:10]
|
||||||
filepath = os.path.join(settings.MEDIA_ROOT, 'flavors', filename)
|
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)
|
||||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
if os.path.exists(abs_path) and os.path.getsize(abs_path) > 0:
|
||||||
|
return rel_path
|
||||||
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()
|
response.raise_for_status()
|
||||||
|
with open(abs_path, "wb") as f:
|
||||||
if os.path.exists(filepath):
|
|
||||||
os.remove(filepath)
|
|
||||||
|
|
||||||
with open(filepath, 'wb') as f:
|
|
||||||
f.write(response.content)
|
f.write(response.content)
|
||||||
|
return rel_path
|
||||||
return f"flavors/{filename}"
|
|
||||||
except Exception as e:
|
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
|
return None
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
@@ -43,106 +46,120 @@ class Command(BaseCommand):
|
|||||||
# Instead, we update existing entries.
|
# Instead, we update existing entries.
|
||||||
|
|
||||||
cat_map = {
|
cat_map = {
|
||||||
'energy': Category.objects.get_or_create(name='Energy', defaults={'slug': 'energy'})[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],
|
"hydration": Category.objects.get_or_create(
|
||||||
'iced-tea': Category.objects.get_or_create(name='Iced Tea', defaults={'slug': 'iced-tea'})[0],
|
name="Hydration", defaults={"slug": "hydration"}
|
||||||
'milkshake': Category.objects.get_or_create(name='Milkshake', defaults={'slug': 'milkshake'})[0],
|
)[0],
|
||||||
'packs': Category.objects.get_or_create(name='Packs and other', defaults={'slug': 'packs-and-other'})[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):
|
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):
|
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
|
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
|
processed_count = 0
|
||||||
|
|
||||||
for json_file in json_files:
|
for json_file in json_files:
|
||||||
file_path = os.path.join(legacy_dir, json_file)
|
file_path = os.path.join(legacy_dir, json_file)
|
||||||
file_lower = json_file.lower()
|
file_lower = json_file.lower()
|
||||||
|
|
||||||
# Determine category from filename
|
# Determine category from filename
|
||||||
if 'energy' in file_lower:
|
if "energy" in file_lower:
|
||||||
file_cat = cat_map['energy']
|
file_cat = cat_map["energy"]
|
||||||
elif 'iced_tea' in file_lower or 'iced-tea' in file_lower:
|
elif "iced_tea" in file_lower or "iced-tea" in file_lower:
|
||||||
file_cat = cat_map['iced-tea']
|
file_cat = cat_map["iced-tea"]
|
||||||
elif 'hydration' in file_lower:
|
elif "hydration" in file_lower:
|
||||||
file_cat = cat_map['hydration']
|
file_cat = cat_map["hydration"]
|
||||||
elif 'milkshake' in file_lower:
|
elif "milkshake" in file_lower:
|
||||||
file_cat = cat_map['milkshake']
|
file_cat = cat_map["milkshake"]
|
||||||
else:
|
else:
|
||||||
file_cat = None
|
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)
|
data_list = json.load(f)
|
||||||
|
|
||||||
for data in data_list:
|
for data in data_list:
|
||||||
name = data.get('Name')
|
name = data.get("Name")
|
||||||
desc = data.get('Beschreibung')
|
desc = data.get("Beschreibung")
|
||||||
taste = data.get('Geschmack')
|
taste = data.get("Geschmack")
|
||||||
status_text = data.get('Status')
|
status_text = data.get("Status")
|
||||||
image_url = data.get('Bild_URL')
|
image_url = data.get("Bild_URL")
|
||||||
|
|
||||||
full_desc = f"**Geschmack:** {taste}\n\n{desc}\n\n**Status:** {status_text}"
|
full_desc = f"**Geschmack:** {taste}\n\n{desc}\n\n**Status:** {status_text}"
|
||||||
|
|
||||||
# Determine final category
|
# Determine final category
|
||||||
cat = file_cat
|
cat = file_cat
|
||||||
if not cat:
|
if not cat:
|
||||||
# Fallback to name/desc detection if filename was ambiguous
|
# Fallback to name/desc detection if filename was ambiguous
|
||||||
if "hydration" in name.lower() or "hydration" in desc.lower():
|
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():
|
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():
|
elif "milkshake" in name.lower():
|
||||||
cat = cat_map['milkshake']
|
cat = cat_map["milkshake"]
|
||||||
elif any(k in name.lower() for k in ['bundle', 'set', 'box', 'probe', 'sample', 'taster', 'shaker', 'starter']):
|
elif any(
|
||||||
cat = cat_map['packs']
|
k in name.lower()
|
||||||
|
for k in [
|
||||||
|
"bundle",
|
||||||
|
"set",
|
||||||
|
"box",
|
||||||
|
"probe",
|
||||||
|
"sample",
|
||||||
|
"taster",
|
||||||
|
"shaker",
|
||||||
|
"starter",
|
||||||
|
]
|
||||||
|
):
|
||||||
|
cat = cat_map["packs"]
|
||||||
else:
|
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
|
# Try to find existing flavor by name and category regardless of legacy status
|
||||||
flavor = Flavor.objects.filter(name__iexact=name, category=cat).first()
|
flavor = Flavor.objects.filter(name__iexact=name, category=cat).first()
|
||||||
|
|
||||||
# If it exists and is an active Shopify product, skip legacy processing
|
# If it exists and is an active Shopify product, skip legacy processing
|
||||||
if flavor and not flavor.is_legacy:
|
if flavor and not flavor.is_legacy:
|
||||||
self.stdout.write(f"Skipping active flavor: {name}")
|
self.stdout.write(f"Skipping active flavor: {name}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not flavor:
|
if not flavor:
|
||||||
flavor = Flavor(
|
flavor = Flavor(name=name, category=cat, is_legacy=True, external_id=None)
|
||||||
name=name,
|
|
||||||
category=cat,
|
|
||||||
is_legacy=True,
|
|
||||||
external_id=None
|
|
||||||
)
|
|
||||||
self.stdout.write(f"Adding new legacy flavor: {name}")
|
self.stdout.write(f"Adding new legacy flavor: {name}")
|
||||||
else:
|
else:
|
||||||
self.stdout.write(f"Updating legacy flavor: {name}")
|
self.stdout.write(f"Updating legacy flavor: {name}")
|
||||||
|
|
||||||
flavor.description = full_desc
|
flavor.description = full_desc
|
||||||
flavor.is_available = False # Legacy is always unavailable via API
|
flavor.is_available = False # Legacy is always unavailable via API
|
||||||
|
|
||||||
if image_url:
|
if image_url:
|
||||||
# Check if local file exists
|
rel_path = self.download_image(image_url, name)
|
||||||
file_exists = False
|
if rel_path:
|
||||||
if flavor.image:
|
flavor.local_image_paths = [rel_path]
|
||||||
abs_path = os.path.join(settings.MEDIA_ROOT, flavor.image.name)
|
if (
|
||||||
if os.path.exists(abs_path):
|
flavor.main_image_path
|
||||||
file_exists = True
|
and flavor.main_image_path not in flavor.local_image_paths
|
||||||
|
):
|
||||||
|
flavor.main_image_path = None
|
||||||
|
self.stdout.write(f" -> Image for: {name}")
|
||||||
|
|
||||||
# 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.image_url = image_url
|
flavor.image_url = image_url
|
||||||
|
flavor.image_urls = [image_url] if image_url else []
|
||||||
flavor.save()
|
flavor.save()
|
||||||
processed_count += 1
|
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 hashlib
|
||||||
import requests
|
|
||||||
import re
|
|
||||||
import os
|
import os
|
||||||
from django.core.management.base import BaseCommand
|
import re
|
||||||
from django.core.files import File
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.text import slugify
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from api.models import Category, Flavor
|
from api.models import Category, Flavor
|
||||||
|
|
||||||
|
_DOWNLOAD_WORKERS = 8
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
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):
|
def clean_html(self, raw_html):
|
||||||
if not raw_html:
|
if not raw_html:
|
||||||
return ""
|
return ""
|
||||||
cleanr = re.compile('<.*?>')
|
cleanr = re.compile("<.*?>")
|
||||||
cleantext = re.sub(cleanr, '', raw_html)
|
cleantext = re.sub(cleanr, "", raw_html)
|
||||||
return cleantext.strip()
|
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:
|
if not url:
|
||||||
return None
|
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:
|
try:
|
||||||
safe_name = slugify(flavor_name)
|
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||||
ext = url.split('.')[-1].split('?')[0].lower()
|
response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
|
||||||
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)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
with open(abs_path, "wb") as f:
|
||||||
if os.path.exists(filepath):
|
|
||||||
os.remove(filepath)
|
|
||||||
|
|
||||||
with open(filepath, 'wb') as f:
|
|
||||||
f.write(response.content)
|
f.write(response.content)
|
||||||
|
return rel_path
|
||||||
return f"flavors/{filename}"
|
|
||||||
except Exception as e:
|
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
|
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):
|
def handle(self, *args, **options):
|
||||||
url = "https://weareholy.com/products.json?limit=250"
|
url = "https://weareholy.com/products.json?limit=250"
|
||||||
self.stdout.write(f"Fetching from {url}...")
|
self.stdout.write(f"Fetching from {url}...")
|
||||||
|
|
||||||
try:
|
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()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.stdout.write(self.style.ERROR(f"Failed to fetch data: {e}"))
|
self.stdout.write(self.style.ERROR(f"Failed to fetch data: {e}"))
|
||||||
return
|
return
|
||||||
|
|
||||||
products = data.get('products', [])
|
products = data.get("products", [])
|
||||||
self.stdout.write(f"Found {len(products)} products.")
|
self.stdout.write(f"Found {len(products)} products.")
|
||||||
|
|
||||||
cat_map = {
|
cat_map = {
|
||||||
'energy': Category.objects.get_or_create(name='Energy', defaults={'slug': 'energy'})[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],
|
"hydration": Category.objects.get_or_create(
|
||||||
'iced-tea': Category.objects.get_or_create(name='Iced Tea', defaults={'slug': 'iced-tea'})[0],
|
name="Hydration", defaults={"slug": "hydration"}
|
||||||
'milkshake': Category.objects.get_or_create(name='Milkshake', defaults={'slug': 'milkshake'})[0],
|
)[0],
|
||||||
'packs': Category.objects.get_or_create(name='Packs and other', defaults={'slug': 'packs-and-other'})[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
|
created_count = 0
|
||||||
@@ -74,102 +116,116 @@ class Command(BaseCommand):
|
|||||||
synced_external_ids = []
|
synced_external_ids = []
|
||||||
|
|
||||||
for p in products:
|
for p in products:
|
||||||
title = p.get('title', '').strip()
|
title = p.get("title", "").strip()
|
||||||
tags = p.get('tags', [])
|
tags = p.get("tags", [])
|
||||||
if isinstance(tags, str):
|
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]
|
tags_lower = [t.lower() for t in tags]
|
||||||
title_lower = title.lower()
|
title_lower = title.lower()
|
||||||
|
|
||||||
is_pack = False
|
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):
|
if any(k in title_lower for k in pack_keywords):
|
||||||
is_pack = True
|
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
|
is_pack = True
|
||||||
|
|
||||||
category = None
|
category = None
|
||||||
if is_pack:
|
if is_pack:
|
||||||
category = cat_map['packs']
|
category = cat_map["packs"]
|
||||||
elif 'holy energy' in tags_lower or 'energy' in tags_lower:
|
elif "holy energy" in tags_lower or "energy" in tags_lower:
|
||||||
category = cat_map['energy']
|
category = cat_map["energy"]
|
||||||
elif 'holy hydration' in tags_lower or 'hydration' in tags_lower:
|
elif "holy hydration" in tags_lower or "hydration" in tags_lower:
|
||||||
category = cat_map['hydration']
|
category = cat_map["hydration"]
|
||||||
elif 'holy iced tea' in tags_lower or 'iced tea' in tags_lower:
|
elif "holy iced tea" in tags_lower or "iced tea" in tags_lower:
|
||||||
category = cat_map['iced-tea']
|
category = cat_map["iced-tea"]
|
||||||
elif 'milkshake' in tags_lower:
|
elif "milkshake" in tags_lower:
|
||||||
category = cat_map['milkshake']
|
category = cat_map["milkshake"]
|
||||||
|
|
||||||
if not category:
|
if not category:
|
||||||
if 'shaker' in tags_lower:
|
if "shaker" in tags_lower:
|
||||||
category = cat_map['packs']
|
category = cat_map["packs"]
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
variants = p.get('variants', [])
|
variants = p.get("variants", [])
|
||||||
is_available = any(v.get('available') for v in variants)
|
is_available = any(v.get("available") for v in variants)
|
||||||
images = p.get('images', [])
|
images = p.get("images", [])
|
||||||
image_url = images[0].get('src') if images else None
|
image_url = images[0].get("src") if images else None
|
||||||
description = self.clean_html(p.get('body_html', ''))
|
image_urls_list = [img.get("src") for img in images if img.get("src")]
|
||||||
handle = p.get('handle')
|
description = self.clean_html(p.get("body_html", ""))
|
||||||
|
handle = p.get("handle")
|
||||||
shop_url = f"https://weareholy.com/products/{handle}" if handle else None
|
shop_url = f"https://weareholy.com/products/{handle}" if handle else None
|
||||||
|
|
||||||
# Find or create - be very specific to avoid duplicates
|
# Find or create - be very specific to avoid duplicates
|
||||||
# 1. Try external_id
|
# 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:
|
if not flavor:
|
||||||
# 2. Try exact name match in category
|
# 2. Try exact name match in category
|
||||||
# REMOVED: is_legacy=False
|
|
||||||
flavor = Flavor.objects.filter(name=title, category=category).first()
|
flavor = Flavor.objects.filter(name=title, category=category).first()
|
||||||
|
|
||||||
if not flavor:
|
if not flavor:
|
||||||
# 3. Try case-insensitive name match in category (safety for whitespace/case)
|
# 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()
|
flavor = Flavor.objects.filter(name__iexact=title, category=category).first()
|
||||||
|
|
||||||
if not flavor:
|
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
|
created_count += 1
|
||||||
else:
|
else:
|
||||||
updated_count += 1
|
updated_count += 1
|
||||||
# Ensure external_id is updated if it was missing
|
|
||||||
if not flavor.external_id:
|
if not flavor.external_id:
|
||||||
flavor.external_id = p['id']
|
flavor.external_id = p["id"]
|
||||||
|
|
||||||
# Reactivate the flavor if it was previously marked as legacy
|
|
||||||
if flavor.is_legacy:
|
if flavor.is_legacy:
|
||||||
flavor.is_legacy = False
|
flavor.is_legacy = False
|
||||||
|
|
||||||
# Ensure all synced fields are accurately reflected
|
if (
|
||||||
if flavor.name != title and not Flavor.objects.filter(name=title, category=category).exclude(pk=flavor.pk).exists():
|
flavor.name != title
|
||||||
|
and not Flavor.objects.filter(name=title, category=category)
|
||||||
|
.exclude(pk=flavor.pk)
|
||||||
|
.exists()
|
||||||
|
):
|
||||||
flavor.name = title
|
flavor.name = title
|
||||||
|
|
||||||
flavor.description = description
|
flavor.description = description
|
||||||
flavor.shop_url = shop_url
|
flavor.shop_url = shop_url
|
||||||
flavor.is_available = is_available
|
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
|
# Download whole gallery into flavors/<external_id>/
|
||||||
if not file_exists or flavor.image_url != image_url:
|
dir_slug = str(p["id"])
|
||||||
rel_path = self.download_image(image_url, title)
|
local_paths = self.download_gallery(image_urls_list, dir_slug)
|
||||||
if rel_path:
|
if local_paths:
|
||||||
flavor.image = rel_path
|
flavor.local_image_paths = local_paths
|
||||||
self.stdout.write(f"Downloaded image for: {title}")
|
# Drop main_image_path if it points to a file no longer in the gallery
|
||||||
else:
|
if flavor.main_image_path and flavor.main_image_path not in local_paths:
|
||||||
self.stdout.write(self.style.WARNING(f"Failed to ensure image for: {title}"))
|
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_url = image_url
|
||||||
|
flavor.image_urls = image_urls_list
|
||||||
flavor.save()
|
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)
|
discontinued_count = (
|
||||||
self.stdout.write(self.style.SUCCESS(f"Finished! Created: {created_count}, Updated: {updated_count}, Marked Unavailable: {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 rest_framework import routers
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
router = routers.DefaultRouter()
|
router = routers.DefaultRouter()
|
||||||
router.register('users', views.UserViewSet)
|
router.register("users", views.UserViewSet)
|
||||||
router.register('flavors', views.FlavorViewSet)
|
router.register("flavors", views.FlavorViewSet)
|
||||||
router.register('categories', views.CategoryViewSet)
|
router.register("categories", views.CategoryViewSet)
|
||||||
router.register('ratings', views.RatingViewSet)
|
router.register("ratings", views.RatingViewSet)
|
||||||
router.register('replies', views.ReplyViewSet)
|
router.register("replies", views.ReplyViewSet)
|
||||||
router.register('notifications', views.NotificationViewSet, basename='notification')
|
router.register("notifications", views.NotificationViewSet, basename="notification")
|
||||||
router.register('tickets', views.TicketViewSet, basename='ticket')
|
router.register("tickets", views.TicketViewSet, basename="ticket")
|
||||||
router.register('admin-custom', views.AdminViewSet, basename='admin-custom')
|
router.register("admin-custom", views.AdminViewSet, basename="admin-custom")
|
||||||
router.register('banners', views.BannerViewSet, basename='banner')
|
router.register("banners", views.BannerViewSet, basename="banner")
|
||||||
|
|
||||||
urlpatterns = [
|
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
|
#!/bin/sh
|
||||||
|
|
||||||
# Exit on error
|
|
||||||
set -e
|
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..."
|
echo "Applying database migrations..."
|
||||||
python manage.py makemigrations
|
python manage.py migrate --noinput
|
||||||
python manage.py migrate
|
|
||||||
|
|
||||||
# Create cache table for ratelimiting
|
|
||||||
echo "Creating cache table..."
|
|
||||||
python manage.py createcachetable
|
|
||||||
|
|
||||||
# Collect static files
|
|
||||||
echo "Collecting static files..."
|
echo "Collecting static files..."
|
||||||
python manage.py collectstatic --no-input
|
python manage.py collectstatic --no-input
|
||||||
|
|
||||||
# Ensure media and cache directories exist
|
|
||||||
mkdir -p media/flavors
|
mkdir -p media/flavors
|
||||||
mkdir -p django_cache
|
|
||||||
chmod -R 777 django_cache
|
|
||||||
|
|
||||||
# Sync flavors from the API
|
# One-shot seed jobs. These should run on image first boot, not on every
|
||||||
echo "Syncing flavors..."
|
# restart. A marker file on the bind-mounted volume gates them.
|
||||||
python manage.py sync_flavors
|
SEED_MARKER="/app/.seeded"
|
||||||
|
if [ ! -f "$SEED_MARKER" ]; then
|
||||||
# Merge any duplicates that might have been created
|
echo "First boot — running initial seed commands..."
|
||||||
echo "Cleaning up duplicates..."
|
python manage.py sync_flavors || echo "sync_flavors failed (non-fatal on first boot)"
|
||||||
python manage.py cleanup_duplicates
|
python manage.py cleanup_duplicates || echo "cleanup_duplicates failed (non-fatal)"
|
||||||
|
python manage.py seed_legacy_flavors || echo "seed_legacy_flavors failed (non-fatal)"
|
||||||
# Seed legacy flavors if needed
|
python manage.py seed_banners || echo "seed_banners failed (non-fatal)"
|
||||||
echo "Seeding legacy flavors..."
|
touch "$SEED_MARKER"
|
||||||
python manage.py seed_legacy_flavors
|
else
|
||||||
|
echo "Seed marker present — skipping seed commands. Trigger them from /admin if needed."
|
||||||
# Auto-sync banner models and settings from JSON files
|
fi
|
||||||
echo "Syncing banner configurations..."
|
|
||||||
python manage.py seed_banners
|
|
||||||
|
|
||||||
if [ "$#" -gt 0 ]; then
|
if [ "$#" -gt 0 ]; then
|
||||||
echo "Executing custom command: $@"
|
echo "Executing custom command: $@"
|
||||||
exec "$@"
|
exec "$@"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Start the server using Gunicorn
|
|
||||||
echo "Starting Gunicorn..."
|
echo "Starting Gunicorn..."
|
||||||
exec gunicorn holy_backend.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 120
|
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
|
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()
|
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
|
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):
|
def health_check(request):
|
||||||
return HttpResponse("OK", content_type="text/plain")
|
return HttpResponse("OK", content_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
# Clean the admin path
|
# Clean the admin path
|
||||||
admin_url = os.environ.get('ADMIN_URL', 'admin').strip('/')
|
admin_url = os.environ.get("ADMIN_URL", "admin").strip("/")
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Health check
|
# Health check
|
||||||
path('health/', health_check, name='health_check'),
|
path("health/", health_check, name="health_check"),
|
||||||
|
|
||||||
# Robots.txt
|
# 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
|
# Ensure admin ends with a slash for Django's redirection logic
|
||||||
path(f'{admin_url}/', admin.site.urls),
|
path(f"{admin_url}/", admin.site.urls),
|
||||||
path('api/', include('api.urls')),
|
path("api/", include("api.urls")),
|
||||||
path('api/token/', obtain_auth_token, name='api_token'),
|
path("api/auth/token/", CookieTokenObtainPairView.as_view(), name="token_obtain_pair"),
|
||||||
path('api-auth/', include('rest_framework.urls')),
|
path("api/auth/token/refresh/", CookieTokenRefreshView.as_view(), name="token_refresh"),
|
||||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
path("api/auth/token/verify/", TokenVerifyView.as_view(), name="token_verify"),
|
||||||
path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
path("api/auth/logout/", CookieLogoutView.as_view(), name="auth_logout"),
|
||||||
path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
|
# Legacy alias: old DRF TokenAuth endpoint now sets cookies just like the
|
||||||
path('media/<path:path>', serve, {'document_root': settings.MEDIA_ROOT}),
|
# 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
|
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()
|
application = get_wsgi_application()
|
||||||
+3
-2
@@ -1,12 +1,13 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
"""Django's command-line utility for administrative tasks."""
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Run administrative tasks."""
|
"""Run administrative tasks."""
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'holy_backend.settings')
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
|
||||||
try:
|
try:
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
@@ -18,5 +19,5 @@ def main():
|
|||||||
execute_from_command_line(sys.argv)
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
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-ratelimit>=4.1.0
|
||||||
django-compression-middleware>=0.5.0
|
django-compression-middleware>=0.5.0
|
||||||
requests>=2.31.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:
|
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 Service ---
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
@@ -8,7 +27,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- holy-net
|
- holy-net
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- 8000
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
@@ -17,6 +36,12 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- DOMAIN=${DOMAIN:-localhost}
|
- 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:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health/"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health/"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -28,20 +53,84 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
container_name: holy-worker
|
container_name: holy-worker
|
||||||
command: python manage.py worker
|
command: celery -A holy_backend worker -l info --concurrency=2
|
||||||
|
user: "app"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- holy-net
|
- holy-net
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
- DOMAIN=${DOMAIN:-localhost}
|
- 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:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- ./legacy:/app/legacy
|
- ./legacy:/app/legacy
|
||||||
depends_on:
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
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:
|
frontend:
|
||||||
build:
|
build:
|
||||||
@@ -56,7 +145,7 @@ services:
|
|||||||
container_name: holy-frontend
|
container_name: holy-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- 80
|
||||||
networks:
|
networks:
|
||||||
- holy-net
|
- holy-net
|
||||||
env_file:
|
env_file:
|
||||||
@@ -71,3 +160,4 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
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,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
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",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"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": {
|
"dependencies": {
|
||||||
"@catppuccin/palette": "^1.7.1",
|
"@catppuccin/palette": "^1.7.1",
|
||||||
@@ -15,8 +23,11 @@
|
|||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^7.3.8",
|
"@mui/icons-material": "^7.3.8",
|
||||||
"@mui/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",
|
"axios": "^1.13.5",
|
||||||
"colorthief": "^2.6.0",
|
"colorthief": "^2.6.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
"i18next": "^24.2.2",
|
"i18next": "^24.2.2",
|
||||||
"i18next-browser-languagedetector": "^8.0.2",
|
"i18next-browser-languagedetector": "^8.0.2",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
@@ -26,17 +37,26 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@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/colorthief": "^2.6.1",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"@vitest/ui": "^4.1.4",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"jsdom": "^29.0.2",
|
||||||
|
"openapi-typescript": "^7.13.0",
|
||||||
|
"prettier": "^3.8.2",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.48.0",
|
"typescript-eslint": "^8.48.0",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1",
|
||||||
|
"vitest": "^4.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+298
-659
File diff suppressed because it is too large.
Load diff
@@ -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 205 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user