mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
removed legacy backend
This commit is contained in:
1 parent
5ff5da2aac
commit
4f285e589f
121 files changed
+45
-5886
No files matched your search
+6
-10
@@ -1,6 +1,5 @@
|
|||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
backend/venv/
|
|
||||||
.venv/
|
.venv/
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
@@ -9,21 +8,18 @@ ENV/
|
|||||||
# Databases
|
# Databases
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
backend/db.sqlite3-shm
|
data/db.sqlite3-shm
|
||||||
backend/db.sqlite3-wal
|
data/db.sqlite3-wal
|
||||||
|
|
||||||
# Python cache
|
# Python cache
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
backend/**/__pycache__/
|
|
||||||
|
|
||||||
# Django specific
|
# Data specific
|
||||||
backend/staticfiles/
|
data/media/
|
||||||
backend/media/
|
data/backups/
|
||||||
backend/django_cache/
|
data/.seeded
|
||||||
backend/backups/
|
|
||||||
backend/.seeded
|
|
||||||
|
|
||||||
# React/Vite build
|
# React/Vite build
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
@@ -36,14 +36,14 @@ The easiest way to get the full stack (Rust backend + Frontend) running behind a
|
|||||||
```
|
```
|
||||||
The stack brings up two containers: `backend-rs` (`holy-rust:8001`, the Rust API) and `frontend` (Nginx — serves the SPA, proxies `/api` to the backend, serves `/media` from the shared mount). `docker-compose.override.yml` adds the Traefik production overlay.
|
The stack brings up two containers: `backend-rs` (`holy-rust:8001`, the Rust API) and `frontend` (Nginx — serves the SPA, proxies `/api` to the backend, serves `/media` from the shared mount). `docker-compose.override.yml` adds the Traefik production overlay.
|
||||||
|
|
||||||
The Rust backend reads the existing `backend/db.sqlite3` directly (no migration step). Seeding (flavor catalog, legacy data, banners) runs as background jobs — trigger them from the admin Jobs tab or let the scheduler run them.
|
The Rust backend reads the existing `data/db.sqlite3` directly (no migration step). Seeding (flavor catalog, legacy data, banners) runs as background jobs — trigger them from the admin Jobs tab or let the scheduler run them.
|
||||||
|
|
||||||
### Option 2: Local Manual Setup (Development)
|
### Option 2: Local Manual Setup (Development)
|
||||||
|
|
||||||
#### Backend
|
#### Backend
|
||||||
```bash
|
```bash
|
||||||
cd backend-rs
|
cd backend-rs
|
||||||
cp .env.example .env # set SECRET_KEY; point DATABASE_URL at ../backend/db.sqlite3
|
cp .env.example .env # set SECRET_KEY; point DATABASE_URL at ../data/db.sqlite3
|
||||||
cargo run # listens on 0.0.0.0:8001
|
cargo run # listens on 0.0.0.0:8001
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
> The Rust backend uses the existing SQLite database under `backend/db.sqlite3`. The `backend/` directory is retained as the data store (db, `media/`, `banners/`) and historical reference for the retired Django implementation — it is no longer run.
|
> The Rust backend uses the existing SQLite database under `data/db.sqlite3`. The `data/` directory is retained as the data store (db, `media/`, `banners/`, `backups/`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -96,13 +96,13 @@ Run by the scheduler on their interval, or triggered from the admin Jobs tab (`P
|
|||||||
- `sync_flavors`: Syncs the latest products from the official Holy Energy (Shopify) catalog.
|
- `sync_flavors`: Syncs the latest products from the official Holy Energy (Shopify) catalog.
|
||||||
- `cleanup_duplicates`: Merges duplicate entries and maintains rating integrity.
|
- `cleanup_duplicates`: Merges duplicate entries and maintains rating integrity.
|
||||||
- `seed_legacy`: Loads retired flavors from `legacy/*.json`.
|
- `seed_legacy`: Loads retired flavors from `legacy/*.json`.
|
||||||
- `seed_banners`: Updates procedurally generated banner configurations from `backend/banners/*.json`.
|
- `seed_banners`: Updates procedurally generated banner configurations from `data/banners/*.json`.
|
||||||
- `backup_db`: Creates a consistent SQLite + media snapshot in `backend/backups/`.
|
- `backup_db`: Creates a consistent SQLite + media snapshot in `data/backups/`.
|
||||||
|
|
||||||
#### Categories
|
#### Categories
|
||||||
`sync_flavors` derives a flavor's category from the Shopify `product_type` (e.g. `42 - Syrup Bottle` → **Syrup**), stripping the numeric prefix and form-factor word. Any new drink line Holy ships **auto-creates** its category on the next sync. Packs, shakers, merch, stickers and other non-drink types funnel to **Packs and other**.
|
`sync_flavors` derives a flavor's category from the Shopify `product_type` (e.g. `42 - Syrup Bottle` → **Syrup**), stripping the numeric prefix and form-factor word. Any new drink line Holy ships **auto-creates** its category on the next sync. Packs, shakers, merch, stickers and other non-drink types funnel to **Packs and other**.
|
||||||
|
|
||||||
`sync_flavors` only assigns a category when it **creates** a row — existing flavors keep their original category. To re-shelve already-stored rows, the retired Django app's `recategorize_flavors` management command remains available under `backend/` for occasional manual use (it is not part of the Rust job registry).
|
`sync_flavors` only assigns a category when it **creates** a row — existing flavors keep their original category. To re-shelve already-stored rows, you can run manual SQL updates directly against the SQLite database.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ Run by the scheduler on their interval, or triggered from the admin Jobs tab (`P
|
|||||||
```bash
|
```bash
|
||||||
bash scripts/backup.sh
|
bash scripts/backup.sh
|
||||||
```
|
```
|
||||||
Creates a `.tar.gz` (DB + media) in `backend/backups/`. WAL-safe: uses `sqlite3 .backup` when available, otherwise copies the DB plus its `-wal`/`-shm` sidecars. The backend also runs `backup_db` on its own schedule.
|
Creates a `.tar.gz` (DB + media) in `data/backups/`. WAL-safe: uses `sqlite3 .backup` when available, otherwise copies the DB plus its `-wal`/`-shm` sidecars. The backend also runs `backup_db` on its own schedule.
|
||||||
|
|
||||||
**Automating with Cron** — nightly at 2:00 AM:
|
**Automating with Cron** — nightly at 2:00 AM:
|
||||||
```bash
|
```bash
|
||||||
@@ -124,8 +124,8 @@ Creates a `.tar.gz` (DB + media) in `backend/backups/`. WAL-safe: uses `sqlite3
|
|||||||
2. Stop the containers: `docker compose down`
|
2. Stop the containers: `docker compose down`
|
||||||
3. Replace the current files:
|
3. Replace the current files:
|
||||||
```bash
|
```bash
|
||||||
cp extracted_folder/db.sqlite3 backend/db.sqlite3
|
cp extracted_folder/db.sqlite3 data/db.sqlite3
|
||||||
cp -r extracted_folder/media/* backend/media/
|
cp -r extracted_folder/media/* data/media/
|
||||||
```
|
```
|
||||||
4. Restart: `docker compose up -d`
|
4. Restart: `docker compose up -d`
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Path to the sqlite database. Relative paths resolve from the process CWD;
|
# Path to the sqlite database. Relative paths resolve from the process CWD;
|
||||||
# absolute is safest. Use mode=rw in prod so a wrong path fails loudly.
|
# absolute is safest. Use mode=rw in prod so a wrong path fails loudly.
|
||||||
DATABASE_URL=sqlite://../backend/db.sqlite3?mode=rwc
|
DATABASE_URL=sqlite://../data/db.sqlite3?mode=rwc
|
||||||
|
|
||||||
# Signs the SimpleJWT-compatible tokens. Keep this CONSTANT across deploys and
|
# Signs the SimpleJWT-compatible tokens. Keep this CONSTANT across deploys and
|
||||||
# restarts — changing it invalidates every issued access/refresh token. (Use the
|
# restarts — changing it invalidates every issued access/refresh token. (Use the
|
||||||
@@ -14,7 +14,7 @@ BIND_ADDR=0.0.0.0:8001
|
|||||||
MEDIA_URL=media/
|
MEDIA_URL=media/
|
||||||
|
|
||||||
# Filesystem root for uploaded media (avatars).
|
# Filesystem root for uploaded media (avatars).
|
||||||
MEDIA_ROOT=../backend/media
|
MEDIA_ROOT=../data/media
|
||||||
|
|
||||||
# Base URL of the frontend, used to build email links (verify-email, reset).
|
# Base URL of the frontend, used to build email links (verify-email, reset).
|
||||||
FRONTEND_URL=http://localhost:5173
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|||||||
+8
-12
@@ -1,17 +1,13 @@
|
|||||||
# holy_backend_rs — Rust backend (strangler migration)
|
# holy_backend_rs — Rust backend (strangler migration)
|
||||||
|
|
||||||
Incremental Rust rewrite of the Django backend. Both backends run side by side
|
Rust backend for the Holy Flavor Rater. The original Django backend has been fully retired and deleted.
|
||||||
and **share the same SQLite file**; an nginx proxy routes already-ported
|
|
||||||
endpoint groups to Rust and everything else to Django. Endpoints move group by
|
|
||||||
group until Django can be retired.
|
|
||||||
|
|
||||||
- **Web**: Axum 0.8 + utoipa (OpenAPI) + Swagger UI
|
- **Web**: Axum 0.8 + utoipa (OpenAPI) + Swagger UI
|
||||||
- **DB**: SeaORM over the existing SQLite (`backend/db.sqlite3`), schema unchanged
|
- **DB**: SeaORM over the SQLite database (`data/db.sqlite3`), schema unchanged
|
||||||
- **Auth**: SimpleJWT-compatible — verifies *and issues* HS256 tokens signed
|
- **Auth**: SimpleJWT-compatible — verifies *and issues* HS256 tokens signed
|
||||||
with the same `SECRET_KEY` as Django (`Authorization: Bearer` or `access_token`
|
with the `SECRET_KEY` (`Authorization: Bearer` or `access_token`
|
||||||
cookie). Login/refresh/verify/logout are ported; tokens are interchangeable
|
cookie). Login/refresh/verify/logout are ported.
|
||||||
with Django (verified both directions).
|
- **Jobs**: tokio-cron-scheduler in-process (replaces Celery+beat)
|
||||||
- **Jobs**: tokio-cron-scheduler in-process (not yet wired; replaces Celery+beat)
|
|
||||||
|
|
||||||
## Ported so far (read slice)
|
## Ported so far (read slice)
|
||||||
|
|
||||||
@@ -200,7 +196,7 @@ The Rust backend is the production API; Django/Celery/Redis were retired after
|
|||||||
the strangler migration completed. `docker compose` (`docker-compose.yml` +
|
the strangler migration completed. `docker compose` (`docker-compose.yml` +
|
||||||
`docker-compose.override.yml`) runs two services: `backend-rs` (`holy-rust:8001`)
|
`docker-compose.override.yml`) runs two services: `backend-rs` (`holy-rust:8001`)
|
||||||
and `frontend` (nginx). nginx serves the SPA, proxies `/api` to the Rust backend,
|
and `frontend` (nginx). nginx serves the SPA, proxies `/api` to the Rust backend,
|
||||||
and serves `/media` from the shared `./backend/media` bind mount; see
|
and serves `/media` from the shared `./data/media` bind mount; see
|
||||||
`frontend/nginx.conf`.
|
`frontend/nginx.conf`.
|
||||||
|
|
||||||
`X-Forwarded-Proto`/`Host` are required: absolute URLs (image links, pagination
|
`X-Forwarded-Proto`/`Host` are required: absolute URLs (image links, pagination
|
||||||
@@ -212,8 +208,8 @@ the peer address otherwise).
|
|||||||
Run a single `backend-rs` instance with `ENABLE_SCHEDULER=true`
|
Run a single `backend-rs` instance with `ENABLE_SCHEDULER=true`
|
||||||
(`RUST_ENABLE_SCHEDULER` in `.env`); any additional instances must leave it off.
|
(`RUST_ENABLE_SCHEDULER` in `.env`); any additional instances must leave it off.
|
||||||
|
|
||||||
The canonical data lives in host bind mounts — `backend/db.sqlite3` (WAL on) and
|
The canonical data lives in host bind mounts — `data/db.sqlite3` (WAL on) and
|
||||||
`backend/media/` — and survives image rebuilds. Keep them.
|
`data/media/` — and survives image rebuilds. Keep them.
|
||||||
|
|
||||||
## Caveats / parity TODO
|
## Caveats / parity TODO
|
||||||
|
|
||||||
|
|||||||
@@ -23,15 +23,14 @@ pub struct Config {
|
|||||||
/// can't use a wildcard, so this is an explicit allow-list.
|
/// can't use a wildcard, so this is an explicit allow-list.
|
||||||
pub cors_allowed_origins: Vec<String>,
|
pub cors_allowed_origins: Vec<String>,
|
||||||
pub email: EmailConfig,
|
pub email: EmailConfig,
|
||||||
/// Directory of banner JSON configs (Django's `backend/banners/`).
|
/// Directory of banner JSON configs (`data/banners/`).
|
||||||
pub banners_dir: String,
|
pub banners_dir: String,
|
||||||
/// Directory of legacy flavor JSON dumps (repo-root `legacy/`).
|
/// Directory of legacy flavor JSON dumps (repo-root `legacy/`).
|
||||||
pub legacy_dir: String,
|
pub legacy_dir: String,
|
||||||
/// Output directory for database/media backups (Django's `backend/backups/`).
|
/// Output directory for database/media backups (`data/backups/`).
|
||||||
pub backup_dir: String,
|
pub backup_dir: String,
|
||||||
/// Whether the in-process job scheduler runs. MUST be false while Django's
|
/// Whether the in-process job scheduler runs. Production (docker compose)
|
||||||
/// Celery `beat` still owns scheduling — otherwise jobs fire twice against
|
/// sets this to true on exactly one instance to own job scheduling.
|
||||||
/// the shared `api_job` table. Flip to true only after disabling Django beat.
|
|
||||||
pub enable_scheduler: bool,
|
pub enable_scheduler: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,12 +75,12 @@ impl Config {
|
|||||||
// `mode=rw` (not `rwc`): the shared DB is created/owned by Django
|
// `mode=rw` (not `rwc`): the shared DB is created/owned by Django
|
||||||
// and must already exist. `rwc` would silently create an empty DB
|
// and must already exist. `rwc` would silently create an empty DB
|
||||||
// if the path were wrong, masking a misconfiguration.
|
// if the path were wrong, masking a misconfiguration.
|
||||||
.unwrap_or_else(|_| "sqlite://../backend/db.sqlite3?mode=rw".to_string()),
|
.unwrap_or_else(|_| "sqlite://../data/db.sqlite3?mode=rw".to_string()),
|
||||||
secret_key: env::var("SECRET_KEY")
|
secret_key: env::var("SECRET_KEY")
|
||||||
.map_err(|_| anyhow::anyhow!("SECRET_KEY must be set (match Django's)"))?,
|
.map_err(|_| anyhow::anyhow!("SECRET_KEY must be set (match Django's)"))?,
|
||||||
bind_addr: env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:8001".to_string()),
|
bind_addr: env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:8001".to_string()),
|
||||||
media_url: env::var("MEDIA_URL").unwrap_or_else(|_| "media/".to_string()),
|
media_url: env::var("MEDIA_URL").unwrap_or_else(|_| "media/".to_string()),
|
||||||
media_root: env::var("MEDIA_ROOT").unwrap_or_else(|_| "../backend/media".to_string()),
|
media_root: env::var("MEDIA_ROOT").unwrap_or_else(|_| "../data/media".to_string()),
|
||||||
jwt_cookie_secure: env_bool("JWT_AUTH_COOKIE_SECURE", false),
|
jwt_cookie_secure: env_bool("JWT_AUTH_COOKIE_SECURE", false),
|
||||||
frontend_url: env::var("FRONTEND_URL")
|
frontend_url: env::var("FRONTEND_URL")
|
||||||
.unwrap_or_else(|_| "http://localhost:5173".to_string())
|
.unwrap_or_else(|_| "http://localhost:5173".to_string())
|
||||||
@@ -122,9 +121,9 @@ impl Config {
|
|||||||
.unwrap_or_else(|| "noreply@localhost".to_string()),
|
.unwrap_or_else(|| "noreply@localhost".to_string()),
|
||||||
},
|
},
|
||||||
banners_dir: env::var("BANNERS_DIR")
|
banners_dir: env::var("BANNERS_DIR")
|
||||||
.unwrap_or_else(|_| "../backend/banners".to_string()),
|
.unwrap_or_else(|_| "../data/banners".to_string()),
|
||||||
legacy_dir: env::var("LEGACY_DIR").unwrap_or_else(|_| "../legacy".to_string()),
|
legacy_dir: env::var("LEGACY_DIR").unwrap_or_else(|_| "../legacy".to_string()),
|
||||||
backup_dir: env::var("BACKUP_DIR").unwrap_or_else(|_| "../backend/backups".to_string()),
|
backup_dir: env::var("BACKUP_DIR").unwrap_or_else(|_| "../data/backups".to_string()),
|
||||||
// Default OFF so ad-hoc `cargo run` instances don't run jobs.
|
// Default OFF so ad-hoc `cargo run` instances don't run jobs.
|
||||||
// Production (docker compose) sets ENABLE_SCHEDULER=true on the one
|
// Production (docker compose) sets ENABLE_SCHEDULER=true on the one
|
||||||
// instance that should own `api_job` scheduling.
|
// instance that should own `api_job` scheduling.
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
# Virtual environments
|
|
||||||
venv/
|
|
||||||
.venv/
|
|
||||||
env/
|
|
||||||
ENV/
|
|
||||||
|
|
||||||
# Python cache
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
*.py[cod]
|
|
||||||
|
|
||||||
# Tooling caches
|
|
||||||
.mypy_cache/
|
|
||||||
.pytest_cache/
|
|
||||||
.ruff_cache/
|
|
||||||
|
|
||||||
# Database
|
|
||||||
db.sqlite3*
|
|
||||||
|
|
||||||
# Runtime data (bind-mounted at runtime, no need to bake into image)
|
|
||||||
media/
|
|
||||||
backups/
|
|
||||||
staticfiles/
|
|
||||||
django_cache/
|
|
||||||
.seeded
|
|
||||||
|
|
||||||
# Secrets / logs
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# IDEs
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# Use an official Python runtime as a parent image
|
|
||||||
FROM python:3.14-slim
|
|
||||||
|
|
||||||
# Set environment variables
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE 1
|
|
||||||
ENV PYTHONUNBUFFERED 1
|
|
||||||
|
|
||||||
# Set the working directory in the container
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Non-root user for celery workers/beat. UID matches typical host (1000) so
|
|
||||||
# bind-mounted volumes (./backend, ./legacy, media/) stay writable. Override
|
|
||||||
# by rebuilding with --build-arg APP_UID=<your uid> if your host differs.
|
|
||||||
ARG APP_UID=1000
|
|
||||||
ARG APP_GID=1000
|
|
||||||
RUN groupadd -g ${APP_GID} app && useradd -u ${APP_UID} -g app -m -s /bin/bash app
|
|
||||||
|
|
||||||
# Copy the rest of the backend code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Expose the port the app runs on
|
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
# Run the entrypoint script
|
|
||||||
ENTRYPOINT ["./entrypoint.sh"]
|
|
||||||
Whitespace-only changes.
@@ -1,44 +0,0 @@
|
|||||||
from django import forms
|
|
||||||
from django.contrib import admin
|
|
||||||
from django.contrib.auth.admin import UserAdmin
|
|
||||||
|
|
||||||
from .models import Banner, Category, Flavor, Rating, User
|
|
||||||
|
|
||||||
|
|
||||||
class CustomUserAdmin(UserAdmin):
|
|
||||||
fieldsets = UserAdmin.fieldsets + ((None, {"fields": ("theme",)}),)
|
|
||||||
add_fieldsets = UserAdmin.add_fieldsets + ((None, {"fields": ("theme",)}),)
|
|
||||||
|
|
||||||
|
|
||||||
class FlavorAdminForm(forms.ModelForm):
|
|
||||||
class Meta:
|
|
||||||
model = Flavor
|
|
||||||
exclude: list[str] = []
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
instance = kwargs.get("instance")
|
|
||||||
paths = list(instance.local_image_paths or []) if instance else []
|
|
||||||
if paths:
|
|
||||||
choices = [("", "— first available —")] + [(p, p.split("/")[-1]) for p in paths]
|
|
||||||
self.fields["main_image_path"] = forms.ChoiceField(
|
|
||||||
choices=choices,
|
|
||||||
required=False,
|
|
||||||
label="Main image",
|
|
||||||
help_text="Pick which downloaded image is used as the primary.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class FlavorAdmin(admin.ModelAdmin):
|
|
||||||
form = FlavorAdminForm
|
|
||||||
list_display = ("name", "category", "is_available", "is_legacy", "external_id")
|
|
||||||
list_filter = ("category", "is_available", "is_legacy")
|
|
||||||
search_fields = ("name",)
|
|
||||||
readonly_fields = ("local_image_paths",)
|
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(User, CustomUserAdmin)
|
|
||||||
admin.site.register(Category)
|
|
||||||
admin.site.register(Flavor, FlavorAdmin)
|
|
||||||
admin.site.register(Rating)
|
|
||||||
admin.site.register(Banner)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class ApiConfig(AppConfig):
|
|
||||||
name = "api"
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import ssl
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.mail.backends.smtp import EmailBackend
|
|
||||||
|
|
||||||
|
|
||||||
class InsecureEmailBackend(EmailBackend):
|
|
||||||
"""
|
|
||||||
SMTP email backend that allows skipping SSL certificate verification.
|
|
||||||
Useful for local development or internal SMTP servers with self-signed certificates.
|
|
||||||
Set EMAIL_SKIP_CERT_VERIFICATION = True in settings to use.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ssl_context(self):
|
|
||||||
if getattr(settings, "EMAIL_SKIP_CERT_VERIFICATION", False):
|
|
||||||
# The most robust way to bypass verification for self-signed certs
|
|
||||||
context = ssl._create_unverified_context()
|
|
||||||
return context
|
|
||||||
return super().ssl_context
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Creates a safe, consistent backup of the SQLite database and manages rotation."
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument("--output", type=str, help="Specific output path for the backup file")
|
|
||||||
parser.add_argument(
|
|
||||||
"--retention", type=int, default=7, help="Number of days to keep old backups"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--full", action="store_true", help="Create a full archive including media"
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
db_conf = settings.DATABASES["default"]
|
|
||||||
if db_conf["ENGINE"] != "django.db.backends.sqlite3":
|
|
||||||
self.stdout.write(self.style.ERROR("This command only supports SQLite."))
|
|
||||||
return
|
|
||||||
|
|
||||||
db_path = str(db_conf["NAME"])
|
|
||||||
backup_dir = os.path.join(settings.BASE_DIR, "backups")
|
|
||||||
os.makedirs(backup_dir, exist_ok=True)
|
|
||||||
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
||||||
|
|
||||||
# 1. Database Backup
|
|
||||||
if options["output"]:
|
|
||||||
db_backup_path = options["output"]
|
|
||||||
else:
|
|
||||||
db_backup_path = os.path.join(backup_dir, f"db_backup_{timestamp}.sqlite3")
|
|
||||||
|
|
||||||
self.stdout.write(f"Cloning database {db_path}...")
|
|
||||||
|
|
||||||
try:
|
|
||||||
src = sqlite3.connect(db_path)
|
|
||||||
dst = sqlite3.connect(db_backup_path)
|
|
||||||
with dst:
|
|
||||||
src.backup(dst)
|
|
||||||
dst.close()
|
|
||||||
src.close()
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"DB backup created: {db_backup_path}"))
|
|
||||||
except Exception as e:
|
|
||||||
self.stdout.write(self.style.ERROR(f"DB backup failed: {e}"))
|
|
||||||
return
|
|
||||||
|
|
||||||
# 2. Full Archive (Optional)
|
|
||||||
if options["full"]:
|
|
||||||
import tarfile
|
|
||||||
|
|
||||||
archive_name = f"full_backup_{timestamp}.tar.gz"
|
|
||||||
archive_path = os.path.join(backup_dir, archive_name)
|
|
||||||
self.stdout.write(f"Creating full archive at {archive_path}...")
|
|
||||||
|
|
||||||
try:
|
|
||||||
with tarfile.open(archive_path, "w:gz") as tar:
|
|
||||||
# Add database
|
|
||||||
tar.add(db_backup_path, arcname="db.sqlite3")
|
|
||||||
# Add media
|
|
||||||
media_dir = str(settings.MEDIA_ROOT)
|
|
||||||
if os.path.exists(media_dir):
|
|
||||||
tar.add(media_dir, arcname="media")
|
|
||||||
|
|
||||||
# Remove the raw sqlite backup after bundling it into the archive
|
|
||||||
if not options["output"]:
|
|
||||||
os.remove(db_backup_path)
|
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS("Full archive created successfully."))
|
|
||||||
except Exception as e:
|
|
||||||
self.stdout.write(self.style.ERROR(f"Archive creation failed: {e}"))
|
|
||||||
|
|
||||||
# 3. Cleanup Old Backups
|
|
||||||
retention_days = options["retention"]
|
|
||||||
cutoff = datetime.now() - timedelta(days=retention_days)
|
|
||||||
|
|
||||||
self.stdout.write(f"Cleaning up backups older than {retention_days} days...")
|
|
||||||
for filename in os.listdir(backup_dir):
|
|
||||||
file_path = os.path.join(backup_dir, filename)
|
|
||||||
if os.path.isfile(file_path):
|
|
||||||
file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
|
|
||||||
if file_time < cutoff:
|
|
||||||
try:
|
|
||||||
os.remove(file_path)
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(f" -> Deleted old backup: {filename}")
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.WARNING(f" -> Failed to delete {filename}: {e}")
|
|
||||||
)
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
|
|
||||||
from api.models import Category, Flavor
|
|
||||||
from api.services.flavor import merge_flavors
|
|
||||||
|
|
||||||
SUFFIXES = [
|
|
||||||
r"\s+Energy$",
|
|
||||||
r"\s+Hydration$",
|
|
||||||
r"\s+Iced\s+Tea$",
|
|
||||||
r"\s+\(Legacy\)$",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Identifies and merges duplicate flavors (normalized name and category)"
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument(
|
|
||||||
"--dry-run",
|
|
||||||
action="store_true",
|
|
||||||
help="Report merges without performing them.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no-normalize",
|
|
||||||
action="store_true",
|
|
||||||
help="Match exact names only; do not strip suffixes like ' Energy'.",
|
|
||||||
)
|
|
||||||
|
|
||||||
def normalize_name(self, name: str, strip_suffixes: bool = True) -> str:
|
|
||||||
"""Lowercase, trim, and (optionally) strip common product suffixes."""
|
|
||||||
norm = name.strip()
|
|
||||||
if strip_suffixes:
|
|
||||||
for suffix in SUFFIXES:
|
|
||||||
norm = re.sub(suffix, "", norm, flags=re.IGNORECASE)
|
|
||||||
return norm.strip().lower()
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
dry_run: bool = options.get("dry_run", False)
|
|
||||||
strip_suffixes: bool = not options.get("no_normalize", False)
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
self.stdout.write(self.style.WARNING("DRY RUN — no changes will be made."))
|
|
||||||
|
|
||||||
total_merged = 0
|
|
||||||
|
|
||||||
for category in Category.objects.all():
|
|
||||||
grouped: dict[str, list[Flavor]] = {}
|
|
||||||
for f in Flavor.objects.filter(category=category):
|
|
||||||
grouped.setdefault(self.normalize_name(f.name, strip_suffixes), []).append(f)
|
|
||||||
|
|
||||||
for flavor_set in grouped.values():
|
|
||||||
if len(flavor_set) <= 1:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Keep the catalog row (has external_id) and the oldest on ties.
|
|
||||||
flavor_set.sort(key=lambda x: (x.external_id is None, x.created_at))
|
|
||||||
keep, others = flavor_set[0], flavor_set[1:]
|
|
||||||
|
|
||||||
names = ", ".join(f"'{o.name}' (ID {o.id})" for o in others)
|
|
||||||
self.stdout.write(
|
|
||||||
f"{'[dry-run] would merge' if dry_run else 'Merging'} {names} "
|
|
||||||
f"into '{keep.name}' (Category: {category.name}, ID: {keep.id})"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not dry_run:
|
|
||||||
for other in others:
|
|
||||||
merge_flavors(keep, other)
|
|
||||||
total_merged += len(others)
|
|
||||||
|
|
||||||
verb = "Would merge" if dry_run else "Finished cleaning up"
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"{verb} {total_merged} duplicates."))
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
"""Garbage collect orphan flavor image files.
|
|
||||||
|
|
||||||
Walks MEDIA_ROOT/flavors/ and removes directories whose paths are not referenced
|
|
||||||
by any Flavor.local_image_paths or Flavor.main_image_path. Use --dry-run to
|
|
||||||
preview without deleting.
|
|
||||||
|
|
||||||
Files referenced by the legacy Flavor.image ImageField are also preserved (for
|
|
||||||
backwards compatibility) until that field is dropped.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
|
|
||||||
from api.models import Flavor
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Remove orphan flavor image files / directories not referenced by any Flavor."
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument(
|
|
||||||
"--dry-run",
|
|
||||||
action="store_true",
|
|
||||||
help="Print what would be deleted but don't touch the filesystem.",
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
dry_run: bool = options["dry_run"]
|
|
||||||
flavors_root = Path(settings.MEDIA_ROOT) / "flavors"
|
|
||||||
if not flavors_root.exists():
|
|
||||||
self.stdout.write(self.style.WARNING(f"No flavors dir at {flavors_root}"))
|
|
||||||
return
|
|
||||||
|
|
||||||
# Collect every relative path/directory that should be kept.
|
|
||||||
referenced_files: set[str] = set()
|
|
||||||
referenced_dirs: set[str] = set()
|
|
||||||
legacy_image_files: set[str] = set()
|
|
||||||
|
|
||||||
for flavor in Flavor.objects.all().only("local_image_paths", "main_image_path", "image"):
|
|
||||||
paths = list(flavor.local_image_paths or [])
|
|
||||||
if flavor.main_image_path:
|
|
||||||
paths.append(flavor.main_image_path)
|
|
||||||
for rel in paths:
|
|
||||||
referenced_files.add(rel)
|
|
||||||
referenced_dirs.add(os.path.dirname(rel))
|
|
||||||
if flavor.image:
|
|
||||||
legacy_image_files.add(flavor.image.name)
|
|
||||||
|
|
||||||
# Walk the flavors tree and decide what's unreferenced.
|
|
||||||
deleted_dirs: list[str] = []
|
|
||||||
deleted_files: list[str] = []
|
|
||||||
kept = 0
|
|
||||||
|
|
||||||
for entry in sorted(flavors_root.iterdir()):
|
|
||||||
rel_dir = f"flavors/{entry.name}"
|
|
||||||
if entry.is_dir():
|
|
||||||
if rel_dir in referenced_dirs:
|
|
||||||
# Drop only orphan files inside the referenced dir.
|
|
||||||
for f in entry.iterdir():
|
|
||||||
rel_file = f"{rel_dir}/{f.name}"
|
|
||||||
if f.is_file() and rel_file not in referenced_files:
|
|
||||||
deleted_files.append(rel_file)
|
|
||||||
if not dry_run:
|
|
||||||
f.unlink()
|
|
||||||
else:
|
|
||||||
kept += 1
|
|
||||||
else:
|
|
||||||
deleted_dirs.append(rel_dir)
|
|
||||||
if not dry_run:
|
|
||||||
shutil.rmtree(entry)
|
|
||||||
elif entry.is_file():
|
|
||||||
rel_file = f"flavors/{entry.name}"
|
|
||||||
if rel_file in legacy_image_files or rel_file in referenced_files:
|
|
||||||
kept += 1
|
|
||||||
else:
|
|
||||||
deleted_files.append(rel_file)
|
|
||||||
if not dry_run:
|
|
||||||
entry.unlink()
|
|
||||||
|
|
||||||
verb = "Would delete" if dry_run else "Deleted"
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(f"{verb}: {len(deleted_dirs)} dirs, {len(deleted_files)} files")
|
|
||||||
)
|
|
||||||
for path in deleted_dirs:
|
|
||||||
self.stdout.write(f" dir {path}")
|
|
||||||
for path in deleted_files:
|
|
||||||
self.stdout.write(f" file {path}")
|
|
||||||
self.stdout.write(f"Kept: {kept} entries")
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import requests
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.db import transaction
|
|
||||||
|
|
||||||
from api.management.commands.sync_flavors import Command as SyncCommand
|
|
||||||
from api.models import Category, Flavor
|
|
||||||
from api.services.flavor import merge_flavors
|
|
||||||
|
|
||||||
_CATALOG_URL = "https://weareholy.com/products.json?limit=250"
|
|
||||||
_PACKS_SLUG = "packs-and-other"
|
|
||||||
_PACKS_NAME = "Packs and other"
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = (
|
|
||||||
"Re-evaluate the category of every already-synced flavor against the current "
|
|
||||||
"sync_flavors logic and move mis-shelved rows. Dry-run unless --apply is given. "
|
|
||||||
"Existing flavors keep their creation-time category during normal sync, so this "
|
|
||||||
"is the way to retro-apply categorization changes to rows that already exist."
|
|
||||||
)
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument(
|
|
||||||
"--apply",
|
|
||||||
action="store_true",
|
|
||||||
help="Persist changes. Without this flag the command only reports what would change.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--merge-collisions",
|
|
||||||
action="store_true",
|
|
||||||
help=(
|
|
||||||
"If the target category already holds a flavor with the same name, merge "
|
|
||||||
"this row into it (preserves ratings) instead of skipping. Implies a write; "
|
|
||||||
"only acts under --apply."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def fetch_products(self) -> list[dict]:
|
|
||||||
response = requests.get(_CATALOG_URL, headers={"User-Agent": "Mozilla/5.0"}, timeout=15)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json().get("products", [])
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
apply = options["apply"]
|
|
||||||
merge_collisions = options["merge_collisions"]
|
|
||||||
|
|
||||||
try:
|
|
||||||
products = self.fetch_products()
|
|
||||||
except Exception as e:
|
|
||||||
self.stdout.write(self.style.ERROR(f"Failed to fetch catalog: {e}"))
|
|
||||||
return
|
|
||||||
|
|
||||||
by_id = {p["id"]: p for p in products if p.get("id") is not None}
|
|
||||||
self.stdout.write(f"Fetched {len(by_id)} catalog products.")
|
|
||||||
|
|
||||||
sync = SyncCommand()
|
|
||||||
moved = merged = collisions = unresolved = not_in_catalog = unchanged = 0
|
|
||||||
|
|
||||||
# Everything mutating runs in one transaction. resolve_category auto-creates
|
|
||||||
# categories as a side effect, so dry-run rolls the whole thing back to stay pure.
|
|
||||||
with transaction.atomic():
|
|
||||||
packs_category = Category.objects.get_or_create(
|
|
||||||
slug=_PACKS_SLUG, defaults={"name": _PACKS_NAME}
|
|
||||||
)[0]
|
|
||||||
moved, merged, collisions, unresolved, not_in_catalog, unchanged = self._process(
|
|
||||||
by_id, sync, packs_category, apply, merge_collisions
|
|
||||||
)
|
|
||||||
if not apply:
|
|
||||||
transaction.set_rollback(True)
|
|
||||||
|
|
||||||
verb = "Applied" if apply else "Would change (dry-run)"
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(
|
|
||||||
f"{verb}: moved={moved}, merged={merged}, collisions={collisions}, "
|
|
||||||
f"unresolved={unresolved}, unchanged={unchanged}, not_in_catalog={not_in_catalog}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if not apply and (moved or collisions):
|
|
||||||
self.stdout.write("Re-run with --apply to persist.")
|
|
||||||
|
|
||||||
def _process(self, by_id, sync, packs_category, apply, merge_collisions):
|
|
||||||
moved = merged = collisions = unresolved = not_in_catalog = unchanged = 0
|
|
||||||
|
|
||||||
for flavor in Flavor.objects.filter(external_id__isnull=False).select_related("category"):
|
|
||||||
product = by_id.get(flavor.external_id)
|
|
||||||
if product is None:
|
|
||||||
not_in_catalog += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
title = product.get("title", "").strip()
|
|
||||||
tags = product.get("tags", [])
|
|
||||||
if isinstance(tags, str):
|
|
||||||
tags = [t.strip() for t in tags.split(",")]
|
|
||||||
tags_lower = [t.lower() for t in tags]
|
|
||||||
|
|
||||||
target = sync.resolve_category(product, title.lower(), tags_lower, packs_category)
|
|
||||||
|
|
||||||
if target is None:
|
|
||||||
unresolved += 1
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.WARNING(
|
|
||||||
f" ? {flavor.name!r} [{flavor.category.slug}]: resolver yields no "
|
|
||||||
f"category (product_type={product.get('product_type')!r}) — left as-is"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if target.id == flavor.category_id:
|
|
||||||
unchanged += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
conflict = (
|
|
||||||
Flavor.objects.filter(name=flavor.name, category=target)
|
|
||||||
.exclude(pk=flavor.pk)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if conflict is not None:
|
|
||||||
collisions += 1
|
|
||||||
if merge_collisions:
|
|
||||||
self.stdout.write(
|
|
||||||
f" MERGE {flavor.name!r}: {flavor.category.slug} -> {target.slug} "
|
|
||||||
f"(into existing row #{conflict.pk})"
|
|
||||||
)
|
|
||||||
if apply:
|
|
||||||
merge_flavors(conflict, flavor)
|
|
||||||
merged += 1
|
|
||||||
else:
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.WARNING(
|
|
||||||
f" SKIP {flavor.name!r}: target {target.slug} already has a flavor "
|
|
||||||
f"named {flavor.name!r} (#{conflict.pk}). Re-run with "
|
|
||||||
f"--merge-collisions to fold them."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
self.stdout.write(f" MOVE {flavor.name!r}: {flavor.category.slug} -> {target.slug}")
|
|
||||||
if apply:
|
|
||||||
flavor.category = target
|
|
||||||
flavor.save(update_fields=["category"])
|
|
||||||
moved += 1
|
|
||||||
|
|
||||||
return moved, merged, collisions, unresolved, not_in_catalog, unchanged
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
|
|
||||||
from api.models import Banner
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Seeds banner models and missing settings from JSON files in backend/banners/"
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
# Determine the banners directory relative to the project root
|
|
||||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
|
||||||
banners_dir = os.path.join(project_root, "banners")
|
|
||||||
|
|
||||||
if not os.path.exists(banners_dir):
|
|
||||||
self.stdout.write(self.style.WARNING(f"Banners directory not found at {banners_dir}"))
|
|
||||||
return
|
|
||||||
|
|
||||||
for filename in os.listdir(banners_dir):
|
|
||||||
if filename.endswith(".json"):
|
|
||||||
filepath = os.path.join(banners_dir, filename)
|
|
||||||
try:
|
|
||||||
with open(filepath) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
slug = data.get("slug")
|
|
||||||
if not slug:
|
|
||||||
continue
|
|
||||||
|
|
||||||
banner, created = Banner.objects.get_or_create(
|
|
||||||
slug=slug,
|
|
||||||
defaults={
|
|
||||||
"name": data.get("name", slug),
|
|
||||||
"description": data.get("description", ""),
|
|
||||||
"settings": data.get("settings", {}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if not created:
|
|
||||||
# Sync missing settings from JSON to DB without overwriting existing keys
|
|
||||||
db_settings = banner.settings or {}
|
|
||||||
json_settings = data.get("settings", {})
|
|
||||||
json_schema = data.get("schema", [])
|
|
||||||
|
|
||||||
updated = False
|
|
||||||
# Sync missing settings
|
|
||||||
for key, value in json_settings.items():
|
|
||||||
if key not in db_settings:
|
|
||||||
db_settings[key] = value
|
|
||||||
updated = True
|
|
||||||
|
|
||||||
# Always sync schema as it represents the UI structure
|
|
||||||
if banner.schema != json_schema:
|
|
||||||
banner.schema = json_schema
|
|
||||||
updated = True
|
|
||||||
|
|
||||||
if updated:
|
|
||||||
banner.settings = db_settings
|
|
||||||
banner.save()
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(
|
|
||||||
f"Synced new settings for banner '{slug}' from {filename}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(f"Created new banner '{slug}' from {filename}")
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.ERROR(f"Failed to sync banner from {filename}: {e}")
|
|
||||||
)
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.utils.text import slugify
|
|
||||||
|
|
||||||
from api.models import Category, Flavor
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Adds legacy flavors from JSON files in the legacy/ folder"
|
|
||||||
|
|
||||||
def download_image(self, url, flavor_name):
|
|
||||||
"""Download legacy image into flavors/legacy_<slug>/00_<hash>.<ext>. Returns rel path."""
|
|
||||||
if not url:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
safe_name = slugify(flavor_name)
|
|
||||||
path = urlparse(url).path
|
|
||||||
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
|
|
||||||
if ext not in ("jpg", "jpeg", "png", "webp", "gif"):
|
|
||||||
ext = "png"
|
|
||||||
url_hash = hashlib.sha1(path.encode("utf-8")).hexdigest()[:10]
|
|
||||||
rel_path = os.path.join("flavors", f"legacy_{safe_name}", f"00_{url_hash}.{ext}")
|
|
||||||
abs_path = os.path.join(settings.MEDIA_ROOT, rel_path)
|
|
||||||
if os.path.exists(abs_path) and os.path.getsize(abs_path) > 0:
|
|
||||||
return rel_path
|
|
||||||
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
|
||||||
response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
|
|
||||||
response.raise_for_status()
|
|
||||||
with open(abs_path, "wb") as f:
|
|
||||||
f.write(response.content)
|
|
||||||
return rel_path
|
|
||||||
except Exception as e:
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.WARNING(f"Failed to download image for {flavor_name}: {e}")
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
# We no longer delete everything to preserve user data like ratings
|
|
||||||
# Instead, we update existing entries.
|
|
||||||
|
|
||||||
cat_map = {
|
|
||||||
"energy": Category.objects.get_or_create(name="Energy", defaults={"slug": "energy"})[0],
|
|
||||||
"hydration": Category.objects.get_or_create(
|
|
||||||
name="Hydration", defaults={"slug": "hydration"}
|
|
||||||
)[0],
|
|
||||||
"iced-tea": Category.objects.get_or_create(
|
|
||||||
name="Iced Tea", defaults={"slug": "iced-tea"}
|
|
||||||
)[0],
|
|
||||||
"milkshake": Category.objects.get_or_create(
|
|
||||||
name="Milkshake", defaults={"slug": "milkshake"}
|
|
||||||
)[0],
|
|
||||||
"packs": Category.objects.get_or_create(
|
|
||||||
name="Packs and other", defaults={"slug": "packs-and-other"}
|
|
||||||
)[0],
|
|
||||||
}
|
|
||||||
|
|
||||||
legacy_dir = os.path.join(settings.BASE_DIR, "legacy")
|
|
||||||
if not os.path.exists(legacy_dir):
|
|
||||||
legacy_dir = os.path.join(settings.BASE_DIR, "..", "legacy")
|
|
||||||
|
|
||||||
if not os.path.exists(legacy_dir):
|
|
||||||
self.stdout.write(self.style.ERROR("Legacy directory not found."))
|
|
||||||
return
|
|
||||||
|
|
||||||
json_files = [f for f in os.listdir(legacy_dir) if f.endswith(".json")]
|
|
||||||
processed_count = 0
|
|
||||||
|
|
||||||
for json_file in json_files:
|
|
||||||
file_path = os.path.join(legacy_dir, json_file)
|
|
||||||
file_lower = json_file.lower()
|
|
||||||
|
|
||||||
# Determine category from filename
|
|
||||||
if "energy" in file_lower:
|
|
||||||
file_cat = cat_map["energy"]
|
|
||||||
elif "iced_tea" in file_lower or "iced-tea" in file_lower:
|
|
||||||
file_cat = cat_map["iced-tea"]
|
|
||||||
elif "hydration" in file_lower:
|
|
||||||
file_cat = cat_map["hydration"]
|
|
||||||
elif "milkshake" in file_lower:
|
|
||||||
file_cat = cat_map["milkshake"]
|
|
||||||
else:
|
|
||||||
file_cat = None
|
|
||||||
|
|
||||||
with open(file_path, encoding="utf-8") as f:
|
|
||||||
data_list = json.load(f)
|
|
||||||
|
|
||||||
for data in data_list:
|
|
||||||
name = data.get("Name")
|
|
||||||
desc = data.get("Beschreibung")
|
|
||||||
taste = data.get("Geschmack")
|
|
||||||
status_text = data.get("Status")
|
|
||||||
image_url = data.get("Bild_URL")
|
|
||||||
|
|
||||||
full_desc = f"**Geschmack:** {taste}\n\n{desc}\n\n**Status:** {status_text}"
|
|
||||||
|
|
||||||
# Determine final category
|
|
||||||
cat = file_cat
|
|
||||||
if not cat:
|
|
||||||
# Fallback to name/desc detection if filename was ambiguous
|
|
||||||
if "hydration" in name.lower() or "hydration" in desc.lower():
|
|
||||||
cat = cat_map["hydration"]
|
|
||||||
elif "iced tea" in name.lower() or "eistee" in name.lower():
|
|
||||||
cat = cat_map["iced-tea"]
|
|
||||||
elif "milkshake" in name.lower():
|
|
||||||
cat = cat_map["milkshake"]
|
|
||||||
elif any(
|
|
||||||
k in name.lower()
|
|
||||||
for k in [
|
|
||||||
"bundle",
|
|
||||||
"set",
|
|
||||||
"box",
|
|
||||||
"probe",
|
|
||||||
"sample",
|
|
||||||
"taster",
|
|
||||||
"shaker",
|
|
||||||
"starter",
|
|
||||||
]
|
|
||||||
):
|
|
||||||
cat = cat_map["packs"]
|
|
||||||
else:
|
|
||||||
cat = cat_map["energy"] # Ultimate fallback
|
|
||||||
|
|
||||||
# Try to find existing flavor by name and category regardless of legacy status
|
|
||||||
flavor = Flavor.objects.filter(name__iexact=name, category=cat).first()
|
|
||||||
|
|
||||||
# If it exists and is an active Shopify product, skip legacy processing
|
|
||||||
if flavor and not flavor.is_legacy:
|
|
||||||
self.stdout.write(f"Skipping active flavor: {name}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not flavor:
|
|
||||||
flavor = Flavor(name=name, category=cat, is_legacy=True, external_id=None)
|
|
||||||
self.stdout.write(f"Adding new legacy flavor: {name}")
|
|
||||||
else:
|
|
||||||
self.stdout.write(f"Updating legacy flavor: {name}")
|
|
||||||
|
|
||||||
flavor.description = full_desc
|
|
||||||
flavor.is_available = False # Legacy is always unavailable via API
|
|
||||||
|
|
||||||
if image_url:
|
|
||||||
rel_path = self.download_image(image_url, name)
|
|
||||||
if rel_path:
|
|
||||||
flavor.local_image_paths = [rel_path]
|
|
||||||
if (
|
|
||||||
flavor.main_image_path
|
|
||||||
and flavor.main_image_path not in flavor.local_image_paths
|
|
||||||
):
|
|
||||||
flavor.main_image_path = None
|
|
||||||
self.stdout.write(f" -> Image for: {name}")
|
|
||||||
|
|
||||||
flavor.image_url = image_url
|
|
||||||
flavor.image_urls = [image_url] if image_url else []
|
|
||||||
flavor.save()
|
|
||||||
processed_count += 1
|
|
||||||
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(f"Finished! Processed {processed_count} legacy flavors.")
|
|
||||||
)
|
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.utils.text import slugify
|
|
||||||
|
|
||||||
from api.models import Category, Flavor
|
|
||||||
|
|
||||||
_DOWNLOAD_WORKERS = 8
|
|
||||||
|
|
||||||
# Shopify product_type looks like "42 - Syrup Bottle" / "01 - Energy Bundle".
|
|
||||||
# Strip the numeric prefix, then the trailing form-factor word, to get the
|
|
||||||
# drink family ("Syrup", "Energy", "Iced Tea") that becomes a Category.
|
|
||||||
_PRODUCT_TYPE_PREFIX = re.compile(r"^\s*\d+\s*[-–—]\s*")
|
|
||||||
|
|
||||||
# Form-factor words are Holy's internal SKU taxonomy, NOT a pack signal:
|
|
||||||
# "Energy Bundle" is a single can, "Caipirinha Crab" is its title. Pack
|
|
||||||
# detection stays title-driven (see _is_pack). These only get stripped to
|
|
||||||
# reveal the family.
|
|
||||||
_FORM_WORDS = frozenset(
|
|
||||||
{"bundle", "sachet", "sachetbox", "bottle", "box", "pack", "set", "sachets"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# When the product_type family itself is one of these, it's not a drink line —
|
|
||||||
# funnel to "Packs and other" instead of minting a category for it.
|
|
||||||
_NON_FLAVOR_TOKENS = frozenset(
|
|
||||||
{
|
|
||||||
"value",
|
|
||||||
"shaker",
|
|
||||||
"merch",
|
|
||||||
"sticker",
|
|
||||||
"packaging",
|
|
||||||
"material",
|
|
||||||
"other",
|
|
||||||
"items",
|
|
||||||
"mixed",
|
|
||||||
"collector",
|
|
||||||
"collector's",
|
|
||||||
"free",
|
|
||||||
"products",
|
|
||||||
"naturalrabatt",
|
|
||||||
"sample",
|
|
||||||
"samplebox",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
_PACKS_SLUG = "packs-and-other"
|
|
||||||
_PACKS_NAME = "Packs and other"
|
|
||||||
|
|
||||||
_PACK_TITLE_KEYWORDS = (
|
|
||||||
"bundle",
|
|
||||||
"set",
|
|
||||||
"box",
|
|
||||||
"probe",
|
|
||||||
"sample",
|
|
||||||
"taster",
|
|
||||||
"starter",
|
|
||||||
"collection",
|
|
||||||
"probier",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Accessories (cleaning brushes, spare parts) inherit a drink form-factor in
|
|
||||||
# their product_type ("Reinigungsbürste – Syrup Bottle" -> "42 - Syrup Bottle"),
|
|
||||||
# so family resolution would mis-shelve them as that drink. The title is the
|
|
||||||
# only signal. Funnel to "Packs and other" like shakers.
|
|
||||||
_ACCESSORY_TITLE_KEYWORDS = (
|
|
||||||
"bürste",
|
|
||||||
"reinigung",
|
|
||||||
"ersatzteil",
|
|
||||||
"zubehör",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fallback only — product_type covers these, but some rows lack one.
|
|
||||||
_TAG_FAMILY = (
|
|
||||||
(("holy energy", "energy"), "Energy"),
|
|
||||||
(("holy hydration", "hydration"), "Hydration"),
|
|
||||||
(("holy iced tea", "iced tea"), "Iced Tea"),
|
|
||||||
(("milkshake",), "Milkshake"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _family_from_product_type(product_type: str) -> str | None:
|
|
||||||
"""Return the drink family from a Shopify product_type, or None.
|
|
||||||
|
|
||||||
"42 - Syrup Bottle" -> "Syrup"; "Value Pack" / "Shaker" -> None (non-flavor).
|
|
||||||
"""
|
|
||||||
if not product_type:
|
|
||||||
return None
|
|
||||||
stripped = _PRODUCT_TYPE_PREFIX.sub("", product_type).strip()
|
|
||||||
if not stripped:
|
|
||||||
return None
|
|
||||||
|
|
||||||
tokens = stripped.split()
|
|
||||||
if any(tok.lower() in _NON_FLAVOR_TOKENS for tok in tokens):
|
|
||||||
return None
|
|
||||||
|
|
||||||
while tokens and tokens[-1].lower() in _FORM_WORDS:
|
|
||||||
tokens.pop()
|
|
||||||
if not tokens:
|
|
||||||
return None
|
|
||||||
return " ".join(tokens)
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Syncs flavors from Holy Energy Shopify API"
|
|
||||||
|
|
||||||
def clean_html(self, raw_html):
|
|
||||||
if not raw_html:
|
|
||||||
return ""
|
|
||||||
cleanr = re.compile("<.*?>")
|
|
||||||
cleantext = re.sub(cleanr, "", raw_html)
|
|
||||||
return cleantext.strip()
|
|
||||||
|
|
||||||
def download_image_to_path(self, url, rel_path):
|
|
||||||
"""Download `url` to MEDIA_ROOT/rel_path. Skip if file already exists. Returns rel_path or None."""
|
|
||||||
if not url:
|
|
||||||
return None
|
|
||||||
abs_path = os.path.join(settings.MEDIA_ROOT, rel_path)
|
|
||||||
if os.path.exists(abs_path) and os.path.getsize(abs_path) > 0:
|
|
||||||
return rel_path
|
|
||||||
try:
|
|
||||||
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
|
||||||
response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
|
|
||||||
response.raise_for_status()
|
|
||||||
with open(abs_path, "wb") as f:
|
|
||||||
f.write(response.content)
|
|
||||||
return rel_path
|
|
||||||
except Exception as e:
|
|
||||||
self.stdout.write(self.style.WARNING(f"Failed to download {url}: {e}"))
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _ext_for(url):
|
|
||||||
path = urlparse(url).path
|
|
||||||
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
|
|
||||||
return ext if ext in ("jpg", "jpeg", "png", "webp", "gif") else "png"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _hash_for(url):
|
|
||||||
path = urlparse(url).path
|
|
||||||
return hashlib.sha1(path.encode("utf-8")).hexdigest()[:10]
|
|
||||||
|
|
||||||
def download_gallery(self, image_urls, dir_slug):
|
|
||||||
"""Download every URL into flavors/<dir_slug>/<i>_<hash>.<ext>. Returns list of rel paths.
|
|
||||||
|
|
||||||
Downloads run in parallel; ordering of returned paths matches input ordering so
|
|
||||||
the gallery index is preserved.
|
|
||||||
"""
|
|
||||||
targets: list[tuple[int, str, str]] = []
|
|
||||||
for i, url in enumerate(image_urls):
|
|
||||||
if not url:
|
|
||||||
continue
|
|
||||||
filename = f"{i:02d}_{self._hash_for(url)}.{self._ext_for(url)}"
|
|
||||||
rel = os.path.join("flavors", dir_slug, filename)
|
|
||||||
targets.append((i, url, rel))
|
|
||||||
|
|
||||||
if not targets:
|
|
||||||
return []
|
|
||||||
|
|
||||||
results: dict[int, str] = {}
|
|
||||||
with ThreadPoolExecutor(max_workers=_DOWNLOAD_WORKERS) as ex:
|
|
||||||
futures = {
|
|
||||||
ex.submit(self.download_image_to_path, url, rel): i for i, url, rel in targets
|
|
||||||
}
|
|
||||||
for fut, i in futures.items():
|
|
||||||
saved = fut.result()
|
|
||||||
if saved:
|
|
||||||
results[i] = saved
|
|
||||||
return [results[i] for i, _, _ in targets if i in results]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _category_for_family(family_name: str) -> Category:
|
|
||||||
"""get_or_create a Category from a family name. slugify('Iced Tea') == 'iced-tea',
|
|
||||||
which matches the pre-existing slugs, so known families are reused not duplicated."""
|
|
||||||
slug = slugify(family_name)
|
|
||||||
return Category.objects.get_or_create(slug=slug, defaults={"name": family_name})[0]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_pack(title_lower: str, tags_lower: list[str]) -> bool:
|
|
||||||
if any(k in title_lower for k in _PACK_TITLE_KEYWORDS):
|
|
||||||
return True
|
|
||||||
return "shaker" in title_lower or "merch" in tags_lower
|
|
||||||
|
|
||||||
def resolve_category(self, product, title_lower, tags_lower, packs_category) -> Category | None:
|
|
||||||
"""Pick a Category for a Shopify product. None means skip (no usable signal).
|
|
||||||
|
|
||||||
Order: pack detection (title/tags) -> accessory title -> product_type family
|
|
||||||
(dynamic, auto-creates) -> tag-based family fallback -> shaker tag -> skip.
|
|
||||||
"""
|
|
||||||
if self._is_pack(title_lower, tags_lower):
|
|
||||||
return packs_category
|
|
||||||
|
|
||||||
if any(k in title_lower for k in _ACCESSORY_TITLE_KEYWORDS):
|
|
||||||
return packs_category
|
|
||||||
|
|
||||||
family = _family_from_product_type(product.get("product_type", ""))
|
|
||||||
if family:
|
|
||||||
return self._category_for_family(family)
|
|
||||||
|
|
||||||
for keywords, name in _TAG_FAMILY:
|
|
||||||
if any(k in tags_lower for k in keywords):
|
|
||||||
return self._category_for_family(name)
|
|
||||||
|
|
||||||
if "shaker" in tags_lower:
|
|
||||||
return packs_category
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_syrup_variant_product(product) -> bool:
|
|
||||||
"""True for the single 'Syrup' article whose variants ARE the flavors.
|
|
||||||
|
|
||||||
Unlike every other line, syrup has no standalone per-flavor products — it
|
|
||||||
ships as one bundle ('3er Box Syrup') with a 'Flavor' option. Its variants
|
|
||||||
are expanded into individual Flavor rows (see _sync_syrup_variants).
|
|
||||||
"""
|
|
||||||
if _family_from_product_type(product.get("product_type", "")) != "Syrup":
|
|
||||||
return False
|
|
||||||
has_flavor_option = any(
|
|
||||||
(o.get("name") or "").lower() == "flavor" for o in product.get("options", [])
|
|
||||||
)
|
|
||||||
return has_flavor_option and len(product.get("variants", [])) > 1
|
|
||||||
|
|
||||||
def _upsert_flavor(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
external_id,
|
|
||||||
name,
|
|
||||||
category,
|
|
||||||
description,
|
|
||||||
shop_url,
|
|
||||||
is_available,
|
|
||||||
image_url,
|
|
||||||
image_urls,
|
|
||||||
dir_slug,
|
|
||||||
) -> bool:
|
|
||||||
"""Find-or-create one Flavor row and refresh its fields. Returns True if created.
|
|
||||||
|
|
||||||
Match order: external_id -> exact name in category -> case-insensitive name.
|
|
||||||
"""
|
|
||||||
flavor = Flavor.objects.filter(external_id=external_id).first()
|
|
||||||
if not flavor:
|
|
||||||
flavor = Flavor.objects.filter(name=name, category=category).first()
|
|
||||||
if not flavor:
|
|
||||||
flavor = Flavor.objects.filter(name__iexact=name, category=category).first()
|
|
||||||
|
|
||||||
created = flavor is None
|
|
||||||
if flavor is None:
|
|
||||||
flavor = Flavor(external_id=external_id, name=name, category=category, is_legacy=False)
|
|
||||||
else:
|
|
||||||
if not flavor.external_id:
|
|
||||||
flavor.external_id = external_id
|
|
||||||
if flavor.is_legacy:
|
|
||||||
flavor.is_legacy = False
|
|
||||||
|
|
||||||
if (
|
|
||||||
flavor.name != name
|
|
||||||
and not Flavor.objects.filter(name=name, category=category)
|
|
||||||
.exclude(pk=flavor.pk)
|
|
||||||
.exists()
|
|
||||||
):
|
|
||||||
flavor.name = name
|
|
||||||
|
|
||||||
flavor.description = description
|
|
||||||
flavor.shop_url = shop_url
|
|
||||||
flavor.is_available = is_available
|
|
||||||
|
|
||||||
local_paths = self.download_gallery(image_urls, dir_slug)
|
|
||||||
if local_paths:
|
|
||||||
flavor.local_image_paths = local_paths
|
|
||||||
# Drop main_image_path if it points to a file no longer in the gallery
|
|
||||||
if flavor.main_image_path and flavor.main_image_path not in local_paths:
|
|
||||||
flavor.main_image_path = None
|
|
||||||
elif not flavor.local_image_paths:
|
|
||||||
# No new paths and nothing previously stored — leave both empty
|
|
||||||
flavor.main_image_path = None
|
|
||||||
|
|
||||||
flavor.image_url = image_url
|
|
||||||
flavor.image_urls = image_urls
|
|
||||||
flavor.save()
|
|
||||||
return created
|
|
||||||
|
|
||||||
def _sync_syrup_variants(self, product, synced_external_ids) -> tuple[int, int]:
|
|
||||||
"""Expand the syrup bundle's variants into individual Syrup flavors.
|
|
||||||
|
|
||||||
Each variant id becomes a Flavor.external_id (BigInt, unique) under the Syrup
|
|
||||||
category; the variant's featured_image is its per-flavor image. Returns
|
|
||||||
(created, updated) counts.
|
|
||||||
"""
|
|
||||||
category = self._category_for_family("Syrup")
|
|
||||||
description = self.clean_html(product.get("body_html", ""))
|
|
||||||
handle = product.get("handle")
|
|
||||||
shop_url = f"https://weareholy.com/products/{handle}" if handle else None
|
|
||||||
|
|
||||||
created = updated = 0
|
|
||||||
for v in product.get("variants", []):
|
|
||||||
name = (v.get("option1") or v.get("title") or "").strip()
|
|
||||||
variant_id = v.get("id")
|
|
||||||
if not name or variant_id is None:
|
|
||||||
continue
|
|
||||||
featured = v.get("featured_image") or {}
|
|
||||||
image_url = featured.get("src")
|
|
||||||
image_urls = [image_url] if image_url else []
|
|
||||||
if self._upsert_flavor(
|
|
||||||
external_id=variant_id,
|
|
||||||
name=name,
|
|
||||||
category=category,
|
|
||||||
description=description,
|
|
||||||
shop_url=shop_url,
|
|
||||||
is_available=bool(v.get("available")),
|
|
||||||
image_url=image_url,
|
|
||||||
image_urls=image_urls,
|
|
||||||
dir_slug=str(variant_id),
|
|
||||||
):
|
|
||||||
created += 1
|
|
||||||
else:
|
|
||||||
updated += 1
|
|
||||||
synced_external_ids.append(variant_id)
|
|
||||||
return created, updated
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
url = "https://weareholy.com/products.json?limit=250"
|
|
||||||
self.stdout.write(f"Fetching from {url}...")
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=15)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
except Exception as e:
|
|
||||||
self.stdout.write(self.style.ERROR(f"Failed to fetch data: {e}"))
|
|
||||||
return
|
|
||||||
|
|
||||||
products = data.get("products", [])
|
|
||||||
self.stdout.write(f"Found {len(products)} products.")
|
|
||||||
|
|
||||||
packs_category = Category.objects.get_or_create(
|
|
||||||
slug=_PACKS_SLUG, defaults={"name": _PACKS_NAME}
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
created_count = 0
|
|
||||||
updated_count = 0
|
|
||||||
synced_external_ids: list[int] = []
|
|
||||||
|
|
||||||
for p in products:
|
|
||||||
title = p.get("title", "").strip()
|
|
||||||
tags = p.get("tags", [])
|
|
||||||
if isinstance(tags, str):
|
|
||||||
tags = [t.strip() for t in tags.split(",")]
|
|
||||||
|
|
||||||
tags_lower = [t.lower() for t in tags]
|
|
||||||
title_lower = title.lower()
|
|
||||||
|
|
||||||
# Syrup ships as one article whose variants are the flavors — expand them.
|
|
||||||
if self._is_syrup_variant_product(p):
|
|
||||||
c, u = self._sync_syrup_variants(p, synced_external_ids)
|
|
||||||
created_count += c
|
|
||||||
updated_count += u
|
|
||||||
continue
|
|
||||||
|
|
||||||
category = self.resolve_category(p, title_lower, tags_lower, packs_category)
|
|
||||||
if not category:
|
|
||||||
continue
|
|
||||||
|
|
||||||
variants = p.get("variants", [])
|
|
||||||
is_available = any(v.get("available") for v in variants)
|
|
||||||
images = p.get("images", [])
|
|
||||||
image_url = images[0].get("src") if images else None
|
|
||||||
image_urls_list = [img.get("src") for img in images if img.get("src")]
|
|
||||||
description = self.clean_html(p.get("body_html", ""))
|
|
||||||
handle = p.get("handle")
|
|
||||||
shop_url = f"https://weareholy.com/products/{handle}" if handle else None
|
|
||||||
|
|
||||||
if self._upsert_flavor(
|
|
||||||
external_id=p["id"],
|
|
||||||
name=title,
|
|
||||||
category=category,
|
|
||||||
description=description,
|
|
||||||
shop_url=shop_url,
|
|
||||||
is_available=is_available,
|
|
||||||
image_url=image_url,
|
|
||||||
image_urls=image_urls_list,
|
|
||||||
dir_slug=str(p["id"]),
|
|
||||||
):
|
|
||||||
created_count += 1
|
|
||||||
else:
|
|
||||||
updated_count += 1
|
|
||||||
synced_external_ids.append(p["id"])
|
|
||||||
|
|
||||||
discontinued_count = (
|
|
||||||
Flavor.objects.filter(external_id__isnull=False)
|
|
||||||
.exclude(external_id__in=synced_external_ids)
|
|
||||||
.update(is_available=False)
|
|
||||||
)
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(
|
|
||||||
f"Finished! Created: {created_count}, Updated: {updated_count}, Marked Unavailable: {discontinued_count}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-15 19:16
|
|
||||||
|
|
||||||
import django.contrib.auth.models
|
|
||||||
import django.contrib.auth.validators
|
|
||||||
import django.core.validators
|
|
||||||
import django.db.models.deletion
|
|
||||||
import django.utils.timezone
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('auth', '0012_alter_user_first_name_max_length'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Category',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('name', models.CharField(max_length=50, unique=True)),
|
|
||||||
('slug', models.SlugField(unique=True)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name_plural': 'Categories',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='User',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
|
||||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
|
||||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
|
||||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
|
||||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
|
||||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
|
||||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
|
||||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
|
||||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
|
||||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
|
||||||
('theme', models.CharField(choices=[('latte', 'Latte'), ('frappe', 'Frappé'), ('macchiato', 'Macchiato'), ('mocha', 'Mocha')], default='mocha', max_length=20)),
|
|
||||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
|
||||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'user',
|
|
||||||
'verbose_name_plural': 'users',
|
|
||||||
'abstract': False,
|
|
||||||
},
|
|
||||||
managers=[
|
|
||||||
('objects', django.contrib.auth.models.UserManager()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Flavor',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('name', models.CharField(max_length=100, unique=True)),
|
|
||||||
('description', models.TextField(blank=True)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='flavors', to='api.category')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Rating',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('score', models.IntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(10)])),
|
|
||||||
('comment', models.TextField(blank=True, null=True)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('flavor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ratings', to='api.flavor')),
|
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ratings', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['-created_at'],
|
|
||||||
'unique_together': {('user', 'flavor')},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-15 19:42
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='flavor',
|
|
||||||
name='external_id',
|
|
||||||
field=models.BigIntegerField(blank=True, null=True, unique=True),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='flavor',
|
|
||||||
name='image_url',
|
|
||||||
field=models.URLField(blank=True, max_length=500, null=True),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='flavor',
|
|
||||||
name='is_available',
|
|
||||||
field=models.BooleanField(default=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-15 19:46
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0002_flavor_external_id_flavor_image_url_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='flavor',
|
|
||||||
name='shop_url',
|
|
||||||
field=models.URLField(blank=True, max_length=500, null=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-15 19:58
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0003_flavor_shop_url'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='user',
|
|
||||||
name='email_confirmation_code',
|
|
||||||
field=models.CharField(blank=True, max_length=6, null=True),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='user',
|
|
||||||
name='pending_email',
|
|
||||||
field=models.EmailField(blank=True, max_length=254, null=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-15 22:48
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0004_user_email_confirmation_code_user_pending_email'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='user',
|
|
||||||
name='theme',
|
|
||||||
field=models.CharField(choices=[('latte', 'Latte'), ('frappe', 'Frappé'), ('macchiato', 'Macchiato'), ('mocha', 'Mocha'), ('pink', 'Pastel Pink')], default='mocha', max_length=20),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-15 22:57
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0005_alter_user_theme'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Reply',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('text', models.TextField()),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('rating', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='api.rating')),
|
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-16 12:04
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0006_reply'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='flavor',
|
|
||||||
name='image',
|
|
||||||
field=models.ImageField(blank=True, null=True, upload_to='flavors/'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='flavor',
|
|
||||||
name='is_legacy',
|
|
||||||
field=models.BooleanField(default=False),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='flavor',
|
|
||||||
name='image_url',
|
|
||||||
field=models.URLField(blank=True, max_length=1000, null=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-16 13:11
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0007_flavor_image_flavor_is_legacy_alter_flavor_image_url'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='user',
|
|
||||||
name='theme',
|
|
||||||
field=models.CharField(choices=[('latte', 'Latte'), ('frappe', 'Frappé'), ('macchiato', 'Macchiato'), ('mocha', 'Mocha'), ('pink', 'Pastel Pink'), ('atom', 'Atom One Dark'), ('dracula', 'Dracula'), ('gruvbox', 'Gruvbox Dark'), ('nord', 'Nord'), ('cyberpunk', 'Cyberpunk'), ('forest', 'Forest Dark')], default='mocha', max_length=20),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-16 13:12
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0008_alter_user_theme'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='user',
|
|
||||||
name='theme',
|
|
||||||
field=models.CharField(choices=[('latte', 'Latte'), ('pink', 'Pastel Pink'), ('solarized_light', 'Solarized Light'), ('one_light', 'One Light'), ('paper', 'Paper White'), ('frappe', 'Frappé'), ('macchiato', 'Macchiato'), ('mocha', 'Mocha'), ('atom', 'Atom One Dark'), ('dracula', 'Dracula'), ('gruvbox', 'Gruvbox Dark'), ('nord', 'Nord'), ('cyberpunk', 'Cyberpunk'), ('forest', 'Forest Dark')], default='mocha', max_length=20),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-16 14:16
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0009_alter_user_theme'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='user',
|
|
||||||
name='avatar',
|
|
||||||
field=models.ImageField(blank=True, null=True, upload_to='avatars/'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-16 18:16
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0010_user_avatar'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='flavor',
|
|
||||||
name='name',
|
|
||||||
field=models.CharField(max_length=100),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='flavor',
|
|
||||||
constraint=models.UniqueConstraint(fields=('name', 'category'), name='unique_flavor_name_per_category'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-16 18:43
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0011_alter_flavor_name_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='user',
|
|
||||||
name='following',
|
|
||||||
field=models.ManyToManyField(blank=True, related_name='followers', to=settings.AUTH_USER_MODEL),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-17 02:16
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0012_user_following'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='user',
|
|
||||||
name='language',
|
|
||||||
field=models.CharField(choices=[('en', 'English'), ('de', 'Deutsch')], default='en', max_length=2),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-17 17:05
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0013_user_language'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='user',
|
|
||||||
name='theme',
|
|
||||||
field=models.CharField(choices=[('holy_light', 'Holy Light'), ('holy_dark', 'Holy Dark'), ('latte', 'Latte'), ('pink', 'Pastel Pink'), ('solarized_light', 'Solarized Light'), ('one_light', 'One Light'), ('paper', 'Paper White'), ('frappe', 'Frappé'), ('macchiato', 'Macchiato'), ('mocha', 'Mocha'), ('atom', 'Atom One Dark'), ('dracula', 'Dracula'), ('gruvbox', 'Gruvbox Dark'), ('nord', 'Nord'), ('cyberpunk', 'Cyberpunk'), ('forest', 'Forest Dark')], default='holy_light', max_length=20),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Notification',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('notification_type', models.CharField(choices=[('reply', 'Reply'), ('mention', 'Mention')], max_length=10)),
|
|
||||||
('is_read', models.BooleanField(default=False)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='actions', to=settings.AUTH_USER_MODEL)),
|
|
||||||
('rating', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='api.rating')),
|
|
||||||
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)),
|
|
||||||
('reply', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='api.reply')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['-created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-17 22:17
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0014_alter_user_theme_notification'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Ticket',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('subject', models.CharField(max_length=200)),
|
|
||||||
('description', models.TextField()),
|
|
||||||
('status', models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed')], default='open', max_length=20)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tickets', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['-updated_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='TicketMessage',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('text', models.TextField()),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='api.ticket')),
|
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='UserIP',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('ip_address', models.GenericIPAddressField()),
|
|
||||||
('last_login', models.DateTimeField(auto_now=True)),
|
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ips', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['-last_login'],
|
|
||||||
'unique_together': {('user', 'ip_address')},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-18 02:55
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0015_ticket_ticketmessage_userip'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='notification',
|
|
||||||
name='ticket',
|
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='api.ticket'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='notification',
|
|
||||||
name='notification_type',
|
|
||||||
field=models.CharField(choices=[('reply', 'Reply'), ('mention', 'Mention'), ('ticket_new', 'New Ticket'), ('ticket_reply', 'Ticket Reply')], max_length=20),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-18 03:31
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0016_notification_ticket_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='ProfileComment',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('text', models.TextField()),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='authored_profile_comments', to=settings.AUTH_USER_MODEL)),
|
|
||||||
('profile_owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='profile_comments', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['-created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-18 03:39
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0017_profilecomment'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='category',
|
|
||||||
options={'ordering': ['name'], 'verbose_name_plural': 'Categories'},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-18 03:42
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0018_alter_category_options'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='flavor',
|
|
||||||
options={'ordering': ['name']},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-18 04:44
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0019_alter_flavor_options'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='notification',
|
|
||||||
name='profile_comment',
|
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='api.profilecomment'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='notification',
|
|
||||||
name='notification_type',
|
|
||||||
field=models.CharField(choices=[('reply', 'Reply'), ('mention', 'Mention'), ('ticket_new', 'New Ticket'), ('ticket_reply', 'Ticket Reply'), ('profile_comment', 'Profile Comment')], max_length=20),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-18 05:43
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0020_notification_profile_comment_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='user',
|
|
||||||
name='theme',
|
|
||||||
field=models.CharField(choices=[('holy_light', 'Holy Light'), ('holy_dark', 'Holy Dark'), ('latte', 'Catppuccin Latte'), ('frappe', 'Catppuccin Frappé'), ('macchiato', 'Catppuccin Macchiato'), ('mocha', 'Catppuccin Mocha'), ('pink_pastel', 'Pink Pastel'), ('dracula', 'Dracula'), ('nord', 'Nordic Frost'), ('gruvbox', 'Gruvbox Retro'), ('oceanic', 'Oceanic Deep')], default='holy_light', max_length=20),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-18 06:18
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0021_alter_user_theme'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='user',
|
|
||||||
name='theme',
|
|
||||||
field=models.CharField(choices=[('holy_light', 'Holy Light'), ('holy_dark', 'Holy Dark'), ('latte', 'Catppuccin Latte'), ('frappe', 'Catppuccin Frappé'), ('macchiato', 'Catppuccin Macchiato'), ('mocha', 'Catppuccin Mocha'), ('pink_pastel', 'Pink Pastel'), ('mint_pastel', 'Mint Pastel'), ('lavender_pastel', 'Lavender Pastel'), ('dracula', 'Dracula'), ('nord', 'Nordic Frost'), ('gruvbox', 'Gruvbox Retro'), ('oceanic', 'Oceanic Deep'), ('t0p_sai', 'SAI (T0P)'), ('t0p_trench', 'Trench (T0P)'), ('t0p_blurryface', 'Blurryface (T0P)'), ('t0p_clancy', 'Clancy (T0P)')], default='holy_light', max_length=20),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-18 11:00
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0022_alter_user_theme'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Banner',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('name', models.CharField(max_length=100)),
|
|
||||||
('slug', models.SlugField(unique=True)),
|
|
||||||
('description', models.TextField(blank=True)),
|
|
||||||
('is_active', models.BooleanField(default=False)),
|
|
||||||
('settings', models.JSONField(blank=True, default=dict)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
from django.db import migrations
|
|
||||||
|
|
||||||
def seed_banners(apps, schema_editor):
|
|
||||||
Banner = apps.get_model('api', 'Banner')
|
|
||||||
|
|
||||||
# Create the original Generative Web
|
|
||||||
Banner.objects.get_or_create(
|
|
||||||
slug='generative-web',
|
|
||||||
defaults={
|
|
||||||
'name': 'Generative Web',
|
|
||||||
'description': 'The original complex web interactive banner.',
|
|
||||||
'settings': {
|
|
||||||
'nodeCountBase': 60,
|
|
||||||
'connectionDist': 140,
|
|
||||||
'speed': 0.005,
|
|
||||||
'opacity': 0.35
|
|
||||||
},
|
|
||||||
'is_active': False
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create Matrix Code
|
|
||||||
Banner.objects.get_or_create(
|
|
||||||
slug='matrix',
|
|
||||||
defaults={
|
|
||||||
'name': 'Matrix Code',
|
|
||||||
'description': 'A matrix-style falling code banner using the username.',
|
|
||||||
'settings': {
|
|
||||||
'opacity': 0.6
|
|
||||||
},
|
|
||||||
'is_active': False
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create Nebula Cloud and set as active
|
|
||||||
Banner.objects.get_or_create(
|
|
||||||
slug='nebula',
|
|
||||||
defaults={
|
|
||||||
'name': 'Nebula Cloud',
|
|
||||||
'description': 'An interactive space-themed nebular cloud with fluid movement.',
|
|
||||||
'settings': {
|
|
||||||
'particleCount': 200,
|
|
||||||
'speed': 0.005,
|
|
||||||
'opacity': 0.8,
|
|
||||||
'attractionForce': 0.2
|
|
||||||
},
|
|
||||||
'is_active': True
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0023_banner'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RunPython(seed_banners),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-18 13:29
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0024_seed_banners'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='banner',
|
|
||||||
name='schema',
|
|
||||||
field=models.JSONField(blank=True, default=list),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-18 16:11
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0025_banner_schema'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='banner',
|
|
||||||
name='is_enabled',
|
|
||||||
field=models.BooleanField(default=True, help_text='Allows users to select this banner.'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='user',
|
|
||||||
name='selected_banner',
|
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='api.banner'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='banner',
|
|
||||||
name='is_active',
|
|
||||||
field=models.BooleanField(default=False, help_text='Designates this as the global default banner.'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-19 14:56
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0026_banner_is_enabled_user_selected_banner_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='notification',
|
|
||||||
name='notification_type',
|
|
||||||
field=models.CharField(choices=[('reply', 'Reply'), ('mention', 'Mention'), ('follow', 'Follow'), ('ticket_new', 'New Ticket'), ('ticket_reply', 'Ticket Reply'), ('profile_comment', 'Profile Comment')], max_length=20),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-19 16:26
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0027_alter_notification_notification_type'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Job',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('name', models.CharField(choices=[('sync_flavors', 'Sync Shopify Flavors'), ('cleanup_duplicates', 'Cleanup Duplicate Flavors'), ('backup_db', 'Database Backup'), ('seed_legacy', 'Seed Legacy Data')], max_length=100)),
|
|
||||||
('status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('completed', 'Completed'), ('failed', 'Failed')], default='pending', max_length=20)),
|
|
||||||
('last_run', models.DateTimeField(blank=True, null=True)),
|
|
||||||
('next_run', models.DateTimeField(blank=True, null=True)),
|
|
||||||
('interval_hours', models.IntegerField(default=0, help_text='Set to 0 to disable periodic run.')),
|
|
||||||
('last_output', models.TextField(blank=True)),
|
|
||||||
('error_message', models.TextField(blank=True)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='SystemConfig',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('site_name', models.CharField(default='Holy Flavors Archive', max_length=100)),
|
|
||||||
('maintenance_mode', models.BooleanField(default=False)),
|
|
||||||
('allow_new_signups', models.BooleanField(default=True)),
|
|
||||||
('require_email_verification', models.BooleanField(default=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'System Configuration',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-02-19 17:22
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0028_job_systemconfig'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='job',
|
|
||||||
name='name',
|
|
||||||
field=models.CharField(choices=[('sync_flavors', 'Sync Holy Flavors'), ('cleanup_duplicates', 'Cleanup Duplicate Flavors'), ('backup_db', 'Database Backup'), ('seed_legacy', 'Seed Legacy Data')], max_length=100),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 6.0.4 on 2026-04-11 00:37
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0029_alter_job_name'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='flavor',
|
|
||||||
name='image_urls',
|
|
||||||
field=models.JSONField(blank=True, default=list),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 6.0.4 on 2026-04-24 16:16
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0030_flavor_image_urls'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='job',
|
|
||||||
name='name',
|
|
||||||
field=models.CharField(choices=[('sync_flavors', 'Sync Holy Flavors'), ('cleanup_duplicates', 'Cleanup Duplicate Flavors'), ('backup_db', 'Database Backup'), ('seed_legacy', 'Seed Legacy Data'), ('seed_banners', 'Seed Banner Configurations')], max_length=100),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Generated by Django 6.0.4 on 2026-04-26 18:45
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0031_alter_job_name'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='flavor',
|
|
||||||
name='local_image_paths',
|
|
||||||
field=models.JSONField(blank=True, default=list),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='flavor',
|
|
||||||
name='main_image_path',
|
|
||||||
field=models.CharField(blank=True, max_length=500, null=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 6.0.4 on 2026-04-27 01:54
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0032_flavor_local_image_paths_flavor_main_image_path'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='user',
|
|
||||||
name='drawer_anchor',
|
|
||||||
field=models.CharField(choices=[('left', 'Left'), ('right', 'Right')], default='left', max_length=5),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 6.0.4 on 2026-05-08 20:04
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('api', '0033_user_drawer_anchor'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='user',
|
|
||||||
name='drawer_anchor',
|
|
||||||
field=models.CharField(choices=[('left', 'Left'), ('right', 'Right')], default='right', max_length=5),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
Whitespace-only changes.
@@ -1,22 +0,0 @@
|
|||||||
from api.models.flavor import Category, Flavor
|
|
||||||
from api.models.rating import Rating, Reply
|
|
||||||
from api.models.social import Notification, ProfileComment
|
|
||||||
from api.models.support import Ticket, TicketMessage
|
|
||||||
from api.models.system import Banner, Job, SystemConfig
|
|
||||||
from api.models.user import User, UserIP
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"Banner",
|
|
||||||
"Category",
|
|
||||||
"Flavor",
|
|
||||||
"Job",
|
|
||||||
"Notification",
|
|
||||||
"ProfileComment",
|
|
||||||
"Rating",
|
|
||||||
"Reply",
|
|
||||||
"SystemConfig",
|
|
||||||
"Ticket",
|
|
||||||
"TicketMessage",
|
|
||||||
"User",
|
|
||||||
"UserIP",
|
|
||||||
]
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
from django.db.models import Avg
|
|
||||||
|
|
||||||
|
|
||||||
class Category(models.Model):
|
|
||||||
name = models.CharField(max_length=50, unique=True)
|
|
||||||
slug = models.SlugField(unique=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name_plural = "Categories"
|
|
||||||
ordering = ["name"]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
|
|
||||||
class Flavor(models.Model):
|
|
||||||
category = models.ForeignKey(Category, related_name="flavors", on_delete=models.CASCADE)
|
|
||||||
name = models.CharField(max_length=100)
|
|
||||||
description = models.TextField(blank=True)
|
|
||||||
image_url = models.URLField(max_length=1000, blank=True, null=True)
|
|
||||||
image_urls = models.JSONField(blank=True, default=list)
|
|
||||||
image = models.ImageField(upload_to="flavors/", blank=True, null=True)
|
|
||||||
local_image_paths = models.JSONField(blank=True, default=list)
|
|
||||||
main_image_path = models.CharField(max_length=500, blank=True, null=True)
|
|
||||||
shop_url = models.URLField(max_length=500, blank=True, null=True)
|
|
||||||
is_available = models.BooleanField(default=True)
|
|
||||||
is_legacy = models.BooleanField(default=False)
|
|
||||||
external_id = models.BigIntegerField(unique=True, null=True, blank=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
constraints = [
|
|
||||||
models.UniqueConstraint(
|
|
||||||
fields=["name", "category"],
|
|
||||||
name="unique_flavor_name_per_category",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
ordering = ["name"]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.name} ({self.category.name})"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def get_average_rating(self) -> float:
|
|
||||||
result = self.ratings.aggregate(avg=Avg("score"))["avg"]
|
|
||||||
return result or 0.0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def cached_image_url(self) -> str | None:
|
|
||||||
if self.image:
|
|
||||||
return self.image.url
|
|
||||||
return self.image_url
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
|
|
||||||
class Rating(models.Model):
|
|
||||||
user = models.ForeignKey("api.User", related_name="ratings", on_delete=models.CASCADE)
|
|
||||||
flavor = models.ForeignKey("api.Flavor", related_name="ratings", on_delete=models.CASCADE)
|
|
||||||
score = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(10)])
|
|
||||||
comment = models.TextField(blank=True, null=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
unique_together = ("user", "flavor")
|
|
||||||
ordering = ["-created_at"]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.user.username} - {self.flavor.name}: {self.score}"
|
|
||||||
|
|
||||||
|
|
||||||
class Reply(models.Model):
|
|
||||||
user = models.ForeignKey("api.User", related_name="replies", on_delete=models.CASCADE)
|
|
||||||
rating = models.ForeignKey(Rating, related_name="replies", on_delete=models.CASCADE)
|
|
||||||
text = models.TextField()
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["created_at"]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"Reply by {self.user.username} on {self.rating.id}"
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
|
|
||||||
|
|
||||||
class Notification(models.Model):
|
|
||||||
TYPE_CHOICES = [
|
|
||||||
("reply", "Reply"),
|
|
||||||
("mention", "Mention"),
|
|
||||||
("follow", "Follow"),
|
|
||||||
("ticket_new", "New Ticket"),
|
|
||||||
("ticket_reply", "Ticket Reply"),
|
|
||||||
("profile_comment", "Profile Comment"),
|
|
||||||
]
|
|
||||||
recipient = models.ForeignKey(
|
|
||||||
"api.User", related_name="notifications", on_delete=models.CASCADE
|
|
||||||
)
|
|
||||||
actor = models.ForeignKey("api.User", related_name="actions", on_delete=models.CASCADE)
|
|
||||||
notification_type = models.CharField(max_length=20, choices=TYPE_CHOICES)
|
|
||||||
rating = models.ForeignKey("api.Rating", on_delete=models.CASCADE, null=True, blank=True)
|
|
||||||
reply = models.ForeignKey("api.Reply", on_delete=models.CASCADE, null=True, blank=True)
|
|
||||||
ticket = models.ForeignKey("api.Ticket", on_delete=models.CASCADE, null=True, blank=True)
|
|
||||||
profile_comment = models.ForeignKey(
|
|
||||||
"api.ProfileComment", on_delete=models.CASCADE, null=True, blank=True
|
|
||||||
)
|
|
||||||
is_read = models.BooleanField(default=False)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["-created_at"]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.notification_type} for {self.recipient.username}"
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileComment(models.Model):
|
|
||||||
profile_owner = models.ForeignKey(
|
|
||||||
"api.User", related_name="profile_comments", on_delete=models.CASCADE
|
|
||||||
)
|
|
||||||
author = models.ForeignKey(
|
|
||||||
"api.User", related_name="authored_profile_comments", on_delete=models.CASCADE
|
|
||||||
)
|
|
||||||
text = models.TextField()
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["-created_at"]
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
|
|
||||||
|
|
||||||
class Ticket(models.Model):
|
|
||||||
STATUS_CHOICES = [
|
|
||||||
("open", "Open"),
|
|
||||||
("in_progress", "In Progress"),
|
|
||||||
("resolved", "Resolved"),
|
|
||||||
("closed", "Closed"),
|
|
||||||
]
|
|
||||||
user = models.ForeignKey("api.User", related_name="tickets", on_delete=models.CASCADE)
|
|
||||||
subject = models.CharField(max_length=200)
|
|
||||||
description = models.TextField()
|
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="open")
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["-updated_at"]
|
|
||||||
|
|
||||||
|
|
||||||
class TicketMessage(models.Model):
|
|
||||||
ticket = models.ForeignKey(Ticket, related_name="messages", on_delete=models.CASCADE)
|
|
||||||
user = models.ForeignKey("api.User", on_delete=models.CASCADE)
|
|
||||||
text = models.TextField()
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["created_at"]
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
|
|
||||||
|
|
||||||
class Banner(models.Model):
|
|
||||||
name = models.CharField(max_length=100)
|
|
||||||
slug = models.SlugField(unique=True)
|
|
||||||
description = models.TextField(blank=True)
|
|
||||||
is_active = models.BooleanField(
|
|
||||||
default=False, help_text="Designates this as the global default banner."
|
|
||||||
)
|
|
||||||
is_enabled = models.BooleanField(default=True, help_text="Allows users to select this banner.")
|
|
||||||
settings = models.JSONField(default=dict, blank=True)
|
|
||||||
schema = models.JSONField(default=list, blank=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
if self.is_active:
|
|
||||||
Banner.objects.filter(is_active=True).exclude(pk=self.pk).update(is_active=False)
|
|
||||||
elif not Banner.objects.filter(is_active=True).exclude(pk=self.pk).exists():
|
|
||||||
self.is_active = True
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
|
|
||||||
class SystemConfig(models.Model):
|
|
||||||
"""Singleton model for application-wide settings editable via UI."""
|
|
||||||
|
|
||||||
site_name = models.CharField(max_length=100, default="Holy Flavors Archive")
|
|
||||||
maintenance_mode = models.BooleanField(default=False)
|
|
||||||
allow_new_signups = models.BooleanField(default=True)
|
|
||||||
require_email_verification = models.BooleanField(default=True)
|
|
||||||
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "System Configuration"
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
self.pk = 1
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_solo(cls) -> "SystemConfig":
|
|
||||||
obj, _ = cls.objects.get_or_create(pk=1)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
class Job(models.Model):
|
|
||||||
"""Tracks background jobs, their status and scheduling."""
|
|
||||||
|
|
||||||
JOB_TYPES = [
|
|
||||||
("sync_flavors", "Sync Holy Flavors"),
|
|
||||||
("cleanup_duplicates", "Cleanup Duplicate Flavors"),
|
|
||||||
("backup_db", "Database Backup"),
|
|
||||||
("seed_legacy", "Seed Legacy Data"),
|
|
||||||
("seed_banners", "Seed Banner Configurations"),
|
|
||||||
]
|
|
||||||
STATUS_CHOICES = [
|
|
||||||
("pending", "Pending"),
|
|
||||||
("running", "Running"),
|
|
||||||
("completed", "Completed"),
|
|
||||||
("failed", "Failed"),
|
|
||||||
]
|
|
||||||
|
|
||||||
name = models.CharField(max_length=100, choices=JOB_TYPES)
|
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending")
|
|
||||||
last_run = models.DateTimeField(null=True, blank=True)
|
|
||||||
next_run = models.DateTimeField(null=True, blank=True)
|
|
||||||
interval_hours = models.IntegerField(default=0, help_text="Set to 0 to disable periodic run.")
|
|
||||||
|
|
||||||
last_output = models.TextField(blank=True)
|
|
||||||
error_message = models.TextField(blank=True)
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.get_name_display()} ({self.status})"
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
from django.contrib.auth.models import AbstractUser
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractUser):
|
|
||||||
THEME_CHOICES = [
|
|
||||||
("holy_light", "Holy Light"),
|
|
||||||
("holy_dark", "Holy Dark"),
|
|
||||||
("latte", "Catppuccin Latte"),
|
|
||||||
("frappe", "Catppuccin Frappé"),
|
|
||||||
("macchiato", "Catppuccin Macchiato"),
|
|
||||||
("mocha", "Catppuccin Mocha"),
|
|
||||||
("pink_pastel", "Pink Pastel"),
|
|
||||||
("mint_pastel", "Mint Pastel"),
|
|
||||||
("lavender_pastel", "Lavender Pastel"),
|
|
||||||
("dracula", "Dracula"),
|
|
||||||
("nord", "Nordic Frost"),
|
|
||||||
("gruvbox", "Gruvbox Retro"),
|
|
||||||
("oceanic", "Oceanic Deep"),
|
|
||||||
("t0p_sai", "SAI (T0P)"),
|
|
||||||
("t0p_trench", "Trench (T0P)"),
|
|
||||||
("t0p_blurryface", "Blurryface (T0P)"),
|
|
||||||
("t0p_clancy", "Clancy (T0P)"),
|
|
||||||
]
|
|
||||||
LANGUAGE_CHOICES = [
|
|
||||||
("en", "English"),
|
|
||||||
("de", "Deutsch"),
|
|
||||||
]
|
|
||||||
DRAWER_ANCHOR_CHOICES = [
|
|
||||||
("left", "Left"),
|
|
||||||
("right", "Right"),
|
|
||||||
]
|
|
||||||
theme = models.CharField(max_length=20, choices=THEME_CHOICES, default="holy_light")
|
|
||||||
language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default="en")
|
|
||||||
drawer_anchor = models.CharField(max_length=5, choices=DRAWER_ANCHOR_CHOICES, default="right")
|
|
||||||
avatar = models.ImageField(upload_to="avatars/", blank=True, null=True)
|
|
||||||
pending_email = models.EmailField(max_length=254, blank=True, null=True)
|
|
||||||
email_confirmation_code = models.CharField(max_length=6, blank=True, null=True)
|
|
||||||
following = models.ManyToManyField(
|
|
||||||
"self", symmetrical=False, related_name="followers", blank=True
|
|
||||||
)
|
|
||||||
selected_banner = models.ForeignKey(
|
|
||||||
"api.Banner",
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name="users",
|
|
||||||
)
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
if self.avatar:
|
|
||||||
try:
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
img = Image.open(self.avatar.path)
|
|
||||||
if img.height > 256 or img.width > 256 or img.format != "JPEG":
|
|
||||||
output_size = (256, 256)
|
|
||||||
img.thumbnail(output_size)
|
|
||||||
if img.mode in ("RGBA", "P"):
|
|
||||||
img = img.convert("RGB")
|
|
||||||
img.save(self.avatar.path, "JPEG", quality=90)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.username
|
|
||||||
|
|
||||||
|
|
||||||
class UserIP(models.Model):
|
|
||||||
user = models.ForeignKey(User, related_name="ips", on_delete=models.CASCADE)
|
|
||||||
ip_address = models.GenericIPAddressField()
|
|
||||||
last_login = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
unique_together = ("user", "ip_address")
|
|
||||||
ordering = ["-last_login"]
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
from api.serializers.flavor import CategorySerializer, FlavorSerializer
|
|
||||||
from api.serializers.rating import RatingSerializer, ReplySerializer
|
|
||||||
from api.serializers.social import NotificationSerializer, ProfileCommentSerializer
|
|
||||||
from api.serializers.support import TicketMessageSerializer, TicketSerializer
|
|
||||||
from api.serializers.system import (
|
|
||||||
BannerSerializer,
|
|
||||||
JobSerializer,
|
|
||||||
SystemConfigSerializer,
|
|
||||||
)
|
|
||||||
from api.serializers.user import (
|
|
||||||
AdminUserDetailSerializer,
|
|
||||||
AdminUserListSerializer,
|
|
||||||
UserIPSerializer,
|
|
||||||
UserSerializer,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"AdminUserDetailSerializer",
|
|
||||||
"AdminUserListSerializer",
|
|
||||||
"BannerSerializer",
|
|
||||||
"CategorySerializer",
|
|
||||||
"FlavorSerializer",
|
|
||||||
"JobSerializer",
|
|
||||||
"NotificationSerializer",
|
|
||||||
"ProfileCommentSerializer",
|
|
||||||
"RatingSerializer",
|
|
||||||
"ReplySerializer",
|
|
||||||
"SystemConfigSerializer",
|
|
||||||
"TicketMessageSerializer",
|
|
||||||
"TicketSerializer",
|
|
||||||
"UserIPSerializer",
|
|
||||||
"UserSerializer",
|
|
||||||
]
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
from typing import Any
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
|
|
||||||
def absolute_image_url(serializer: Any, image_field: Any) -> str | None:
|
|
||||||
if not image_field:
|
|
||||||
return None
|
|
||||||
request = serializer.context.get("request")
|
|
||||||
if request:
|
|
||||||
return request.build_absolute_uri(image_field.url)
|
|
||||||
return image_field.url
|
|
||||||
|
|
||||||
|
|
||||||
def absolute_media_url(serializer: Any, rel_path: str | None) -> str | None:
|
|
||||||
if not rel_path:
|
|
||||||
return None
|
|
||||||
media_url = settings.MEDIA_URL.rstrip("/") + "/"
|
|
||||||
url = media_url + rel_path.lstrip("/")
|
|
||||||
request = serializer.context.get("request")
|
|
||||||
if request:
|
|
||||||
return request.build_absolute_uri(url)
|
|
||||||
return url
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
from django.db.models import Count
|
|
||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
from api.models import Category, Flavor, Rating
|
|
||||||
from api.serializers._helpers import absolute_image_url, absolute_media_url
|
|
||||||
from api.serializers.rating import RatingSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class CategorySerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = Category
|
|
||||||
fields = ["id", "name", "slug"]
|
|
||||||
|
|
||||||
|
|
||||||
class FlavorSerializer(serializers.ModelSerializer):
|
|
||||||
category_name = serializers.CharField(source="category.name", read_only=True)
|
|
||||||
category_slug = serializers.CharField(source="category.slug", read_only=True)
|
|
||||||
average_rating = serializers.FloatField(read_only=True)
|
|
||||||
followed_average_rating = serializers.FloatField(read_only=True)
|
|
||||||
user_rating = serializers.SerializerMethodField()
|
|
||||||
ratings = RatingSerializer(many=True, read_only=True)
|
|
||||||
image_url = serializers.SerializerMethodField()
|
|
||||||
image_urls = serializers.SerializerMethodField()
|
|
||||||
rating_distribution = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Flavor
|
|
||||||
fields = [
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"category",
|
|
||||||
"category_name",
|
|
||||||
"category_slug",
|
|
||||||
"description",
|
|
||||||
"average_rating",
|
|
||||||
"followed_average_rating",
|
|
||||||
"user_rating",
|
|
||||||
"ratings",
|
|
||||||
"image_url",
|
|
||||||
"image_urls",
|
|
||||||
"image",
|
|
||||||
"is_available",
|
|
||||||
"is_legacy",
|
|
||||||
"shop_url",
|
|
||||||
"rating_distribution",
|
|
||||||
]
|
|
||||||
extra_kwargs = {"image": {"write_only": True}}
|
|
||||||
|
|
||||||
def get_image_url(self, obj: Flavor) -> str | None:
|
|
||||||
if obj.main_image_path:
|
|
||||||
url = absolute_media_url(self, obj.main_image_path)
|
|
||||||
if url:
|
|
||||||
return url
|
|
||||||
if obj.local_image_paths:
|
|
||||||
url = absolute_media_url(self, obj.local_image_paths[0])
|
|
||||||
if url:
|
|
||||||
return url
|
|
||||||
if obj.image:
|
|
||||||
return absolute_image_url(self, obj.image)
|
|
||||||
return obj.image_url
|
|
||||||
|
|
||||||
def get_image_urls(self, obj: Flavor) -> list[str]:
|
|
||||||
if obj.local_image_paths:
|
|
||||||
urls = [absolute_media_url(self, p) for p in obj.local_image_paths]
|
|
||||||
return [u for u in urls if u]
|
|
||||||
legacy_urls: list[str] = []
|
|
||||||
if obj.image:
|
|
||||||
absolute = absolute_image_url(self, obj.image)
|
|
||||||
if absolute:
|
|
||||||
legacy_urls.append(absolute)
|
|
||||||
legacy_urls.extend(obj.image_urls or [])
|
|
||||||
return legacy_urls
|
|
||||||
|
|
||||||
def get_rating_distribution(self, obj: Flavor) -> dict[str, int]:
|
|
||||||
counts = dict.fromkeys((str(i) for i in range(1, 11)), 0)
|
|
||||||
for row in obj.ratings.values("score").annotate(c=Count("id")):
|
|
||||||
score = row["score"]
|
|
||||||
if 1 <= score <= 10:
|
|
||||||
counts[str(score)] = row["c"]
|
|
||||||
return counts
|
|
||||||
|
|
||||||
def get_user_rating(self, obj: Flavor) -> int | None:
|
|
||||||
request = self.context.get("request")
|
|
||||||
if request and request.user.is_authenticated:
|
|
||||||
try:
|
|
||||||
return request.user.ratings.get(flavor=obj).score
|
|
||||||
except Rating.DoesNotExist:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
from api.models import Rating, Reply
|
|
||||||
from api.serializers._helpers import absolute_image_url
|
|
||||||
|
|
||||||
|
|
||||||
class ReplySerializer(serializers.ModelSerializer):
|
|
||||||
user = serializers.StringRelatedField(read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Reply
|
|
||||||
fields = ["id", "user", "rating", "text", "created_at"]
|
|
||||||
read_only_fields = ["rating"]
|
|
||||||
|
|
||||||
|
|
||||||
class RatingSerializer(serializers.ModelSerializer):
|
|
||||||
user = serializers.StringRelatedField(read_only=True)
|
|
||||||
user_id = serializers.IntegerField(source="user.id", read_only=True)
|
|
||||||
user_avatar = serializers.SerializerMethodField()
|
|
||||||
flavor_name = serializers.CharField(source="flavor.name", read_only=True)
|
|
||||||
flavor_image = serializers.SerializerMethodField()
|
|
||||||
category_name = serializers.CharField(source="flavor.category.name", read_only=True)
|
|
||||||
category_slug = serializers.CharField(source="flavor.category.slug", read_only=True)
|
|
||||||
is_available = serializers.BooleanField(source="flavor.is_available", read_only=True)
|
|
||||||
is_legacy = serializers.BooleanField(source="flavor.is_legacy", read_only=True)
|
|
||||||
replies = ReplySerializer(many=True, read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Rating
|
|
||||||
fields = [
|
|
||||||
"id",
|
|
||||||
"user",
|
|
||||||
"user_id",
|
|
||||||
"user_avatar",
|
|
||||||
"flavor",
|
|
||||||
"flavor_name",
|
|
||||||
"flavor_image",
|
|
||||||
"category_name",
|
|
||||||
"category_slug",
|
|
||||||
"is_available",
|
|
||||||
"is_legacy",
|
|
||||||
"score",
|
|
||||||
"comment",
|
|
||||||
"created_at",
|
|
||||||
"replies",
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_user_avatar(self, obj: Rating) -> str | None:
|
|
||||||
return absolute_image_url(self, obj.user.avatar)
|
|
||||||
|
|
||||||
def get_flavor_image(self, obj: Rating) -> str | None:
|
|
||||||
if obj.flavor.image:
|
|
||||||
return absolute_image_url(self, obj.flavor.image)
|
|
||||||
return obj.flavor.image_url
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
from api.models import Notification, ProfileComment
|
|
||||||
from api.serializers._helpers import absolute_image_url
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileCommentSerializer(serializers.ModelSerializer):
|
|
||||||
author_username = serializers.CharField(source="author.username", read_only=True)
|
|
||||||
author_avatar = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = ProfileComment
|
|
||||||
fields = ["id", "author_username", "author_avatar", "text", "created_at"]
|
|
||||||
|
|
||||||
def get_author_avatar(self, obj: ProfileComment) -> str | None:
|
|
||||||
return absolute_image_url(self, obj.author.avatar)
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationSerializer(serializers.ModelSerializer):
|
|
||||||
actor_username = serializers.CharField(source="actor.username", read_only=True)
|
|
||||||
actor_avatar = serializers.SerializerMethodField()
|
|
||||||
flavor_name = serializers.SerializerMethodField()
|
|
||||||
flavor_id = serializers.SerializerMethodField()
|
|
||||||
profile_owner_username = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Notification
|
|
||||||
fields = [
|
|
||||||
"id",
|
|
||||||
"actor_username",
|
|
||||||
"actor_avatar",
|
|
||||||
"notification_type",
|
|
||||||
"rating",
|
|
||||||
"reply",
|
|
||||||
"ticket",
|
|
||||||
"profile_comment",
|
|
||||||
"profile_owner_username",
|
|
||||||
"is_read",
|
|
||||||
"created_at",
|
|
||||||
"flavor_name",
|
|
||||||
"flavor_id",
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_actor_avatar(self, obj: Notification) -> str | None:
|
|
||||||
return absolute_image_url(self, obj.actor.avatar)
|
|
||||||
|
|
||||||
def get_flavor_name(self, obj: Notification) -> str | None:
|
|
||||||
if obj.rating:
|
|
||||||
return obj.rating.flavor.name
|
|
||||||
if obj.reply:
|
|
||||||
return obj.reply.rating.flavor.name
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_flavor_id(self, obj: Notification) -> int | None:
|
|
||||||
if obj.rating:
|
|
||||||
return obj.rating.flavor.id
|
|
||||||
if obj.reply:
|
|
||||||
return obj.reply.rating.flavor.id
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_profile_owner_username(self, obj: Notification) -> str | None:
|
|
||||||
if obj.profile_comment:
|
|
||||||
return obj.profile_comment.profile_owner.username
|
|
||||||
return None
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
from api.models import Ticket, TicketMessage
|
|
||||||
from api.serializers._helpers import absolute_image_url
|
|
||||||
|
|
||||||
|
|
||||||
class TicketMessageSerializer(serializers.ModelSerializer):
|
|
||||||
username = serializers.CharField(source="user.username", read_only=True)
|
|
||||||
is_admin = serializers.BooleanField(source="user.is_superuser", read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = TicketMessage
|
|
||||||
fields = ["id", "username", "text", "created_at", "is_admin"]
|
|
||||||
|
|
||||||
|
|
||||||
class TicketSerializer(serializers.ModelSerializer):
|
|
||||||
username = serializers.CharField(source="user.username", read_only=True)
|
|
||||||
user_email = serializers.EmailField(source="user.email", read_only=True)
|
|
||||||
user_avatar = serializers.SerializerMethodField()
|
|
||||||
messages = TicketMessageSerializer(many=True, read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Ticket
|
|
||||||
fields = [
|
|
||||||
"id",
|
|
||||||
"user",
|
|
||||||
"username",
|
|
||||||
"user_email",
|
|
||||||
"user_avatar",
|
|
||||||
"subject",
|
|
||||||
"description",
|
|
||||||
"status",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
"messages",
|
|
||||||
]
|
|
||||||
read_only_fields = ["status", "user"]
|
|
||||||
|
|
||||||
def get_user_avatar(self, obj: Ticket) -> str | None:
|
|
||||||
return absolute_image_url(self, obj.user.avatar)
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
from api.models import Banner, Job, SystemConfig
|
|
||||||
|
|
||||||
_INTERVAL_PERIOD_TO_TIMEDELTA = {
|
|
||||||
"days": lambda every: timedelta(days=every),
|
|
||||||
"hours": lambda every: timedelta(hours=every),
|
|
||||||
"minutes": lambda every: timedelta(minutes=every),
|
|
||||||
"seconds": lambda every: timedelta(seconds=every),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class SystemConfigSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = SystemConfig
|
|
||||||
fields = [
|
|
||||||
"site_name",
|
|
||||||
"maintenance_mode",
|
|
||||||
"allow_new_signups",
|
|
||||||
"require_email_verification",
|
|
||||||
"updated_at",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class JobSerializer(serializers.ModelSerializer):
|
|
||||||
name_display = serializers.CharField(source="get_name_display", read_only=True)
|
|
||||||
next_run = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
def get_next_run(self, obj):
|
|
||||||
from django_celery_beat.models import PeriodicTask
|
|
||||||
|
|
||||||
task = PeriodicTask.objects.filter(name=f"job:{obj.name}", enabled=True).first()
|
|
||||||
if not task or not task.interval:
|
|
||||||
return None
|
|
||||||
base = task.last_run_at or obj.last_run
|
|
||||||
if not base:
|
|
||||||
return None
|
|
||||||
delta_fn = _INTERVAL_PERIOD_TO_TIMEDELTA.get(task.interval.period)
|
|
||||||
if not delta_fn:
|
|
||||||
return None
|
|
||||||
return base + delta_fn(task.interval.every)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Job
|
|
||||||
fields = [
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"name_display",
|
|
||||||
"status",
|
|
||||||
"last_run",
|
|
||||||
"next_run",
|
|
||||||
"interval_hours",
|
|
||||||
"last_output",
|
|
||||||
"error_message",
|
|
||||||
]
|
|
||||||
read_only_fields = [
|
|
||||||
"status",
|
|
||||||
"last_run",
|
|
||||||
"next_run",
|
|
||||||
"last_output",
|
|
||||||
"error_message",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class BannerSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = Banner
|
|
||||||
fields = [
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"slug",
|
|
||||||
"description",
|
|
||||||
"is_active",
|
|
||||||
"is_enabled",
|
|
||||||
"settings",
|
|
||||||
"schema",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
]
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
from api.models import User, UserIP
|
|
||||||
from api.serializers._helpers import absolute_image_url
|
|
||||||
from api.serializers.rating import RatingSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
|
||||||
avatar = serializers.SerializerMethodField()
|
|
||||||
following_count = serializers.IntegerField(source="following.count", read_only=True)
|
|
||||||
followers_count = serializers.IntegerField(source="followers.count", read_only=True)
|
|
||||||
is_following = serializers.SerializerMethodField()
|
|
||||||
unread_notifications_count = serializers.SerializerMethodField()
|
|
||||||
is_superuser = serializers.BooleanField(read_only=True)
|
|
||||||
selected_banner_slug = serializers.CharField(
|
|
||||||
source="selected_banner.slug", read_only=True, allow_null=True
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = User
|
|
||||||
fields = [
|
|
||||||
"id",
|
|
||||||
"username",
|
|
||||||
"email",
|
|
||||||
"theme",
|
|
||||||
"language",
|
|
||||||
"drawer_anchor",
|
|
||||||
"avatar",
|
|
||||||
"following_count",
|
|
||||||
"followers_count",
|
|
||||||
"is_following",
|
|
||||||
"unread_notifications_count",
|
|
||||||
"is_superuser",
|
|
||||||
"selected_banner",
|
|
||||||
"selected_banner_slug",
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_avatar(self, obj: User) -> str | None:
|
|
||||||
return absolute_image_url(self, obj.avatar)
|
|
||||||
|
|
||||||
def get_is_following(self, obj: User) -> bool:
|
|
||||||
request = self.context.get("request")
|
|
||||||
if request and request.user.is_authenticated:
|
|
||||||
return request.user.following.filter(pk=obj.pk).exists()
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_unread_notifications_count(self, obj: User) -> int:
|
|
||||||
return obj.notifications.filter(is_read=False).count()
|
|
||||||
|
|
||||||
|
|
||||||
class UserIPSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = UserIP
|
|
||||||
fields = ["id", "ip_address", "last_login"]
|
|
||||||
|
|
||||||
|
|
||||||
class AdminUserListSerializer(serializers.ModelSerializer):
|
|
||||||
ips = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = User
|
|
||||||
fields = [
|
|
||||||
"id",
|
|
||||||
"username",
|
|
||||||
"email",
|
|
||||||
"is_active",
|
|
||||||
"is_superuser",
|
|
||||||
"date_joined",
|
|
||||||
"last_login",
|
|
||||||
"ips",
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_ips(self, obj: User) -> list[str]:
|
|
||||||
return [ip.ip_address for ip in obj.ips.all()[:3]]
|
|
||||||
|
|
||||||
|
|
||||||
class AdminUserDetailSerializer(serializers.ModelSerializer):
|
|
||||||
ips = serializers.SerializerMethodField()
|
|
||||||
ratings = RatingSerializer(many=True, read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = User
|
|
||||||
fields = [
|
|
||||||
"id",
|
|
||||||
"username",
|
|
||||||
"email",
|
|
||||||
"is_active",
|
|
||||||
"is_superuser",
|
|
||||||
"date_joined",
|
|
||||||
"last_login",
|
|
||||||
"ips",
|
|
||||||
"ratings",
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_ips(self, obj: User) -> list[str]:
|
|
||||||
return [ip.ip_address for ip in obj.ips.all()]
|
|
||||||
Whitespace-only changes.
@@ -1,54 +0,0 @@
|
|||||||
from django.db import transaction
|
|
||||||
|
|
||||||
from api.models import Flavor, Notification, Rating, Reply
|
|
||||||
|
|
||||||
|
|
||||||
def _quality(rating: Rating) -> tuple[int, object]:
|
|
||||||
"""Rank a rating for conflict resolution: longest comment wins, newer breaks ties."""
|
|
||||||
return (len(rating.comment or ""), rating.created_at)
|
|
||||||
|
|
||||||
|
|
||||||
def merge_flavors(keep_flavor: Flavor, remove_flavor: Flavor) -> None:
|
|
||||||
"""
|
|
||||||
Merges remove_flavor into keep_flavor.
|
|
||||||
|
|
||||||
Re-assigns all ratings. When a user rated both flavors, the higher-quality
|
|
||||||
review survives (longest comment, newer on ties) and its replies +
|
|
||||||
notifications are re-pointed to the surviving row.
|
|
||||||
"""
|
|
||||||
if keep_flavor.id == remove_flavor.id:
|
|
||||||
return
|
|
||||||
|
|
||||||
with transaction.atomic():
|
|
||||||
for incoming in Rating.objects.filter(flavor=remove_flavor):
|
|
||||||
existing = Rating.objects.filter(user=incoming.user, flavor=keep_flavor).first()
|
|
||||||
|
|
||||||
if existing is None:
|
|
||||||
# No conflict — move the rating wholesale.
|
|
||||||
incoming.flavor = keep_flavor
|
|
||||||
incoming.save(update_fields=["flavor"])
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Conflict: keep one row (unique_together user+flavor). If the
|
|
||||||
# incoming review is better, copy its content + timestamp onto the
|
|
||||||
# surviving row so the comment and its date stay consistent.
|
|
||||||
if _quality(incoming) > _quality(existing):
|
|
||||||
existing.score = incoming.score
|
|
||||||
existing.comment = incoming.comment
|
|
||||||
existing.created_at = incoming.created_at # auto_now_add ignores updates
|
|
||||||
existing.save(update_fields=["score", "comment", "created_at"])
|
|
||||||
|
|
||||||
# Preserve community feedback attached to the discarded rating.
|
|
||||||
Reply.objects.filter(rating=incoming).update(rating=existing)
|
|
||||||
Notification.objects.filter(rating=incoming).update(rating=existing)
|
|
||||||
incoming.delete()
|
|
||||||
|
|
||||||
# Carry over the Shopify link if the kept flavor lacks one. external_id
|
|
||||||
# is unique, so the donor row must be gone before we assign it.
|
|
||||||
inherited_external_id = remove_flavor.external_id if not keep_flavor.external_id else None
|
|
||||||
|
|
||||||
remove_flavor.delete()
|
|
||||||
|
|
||||||
if inherited_external_id is not None:
|
|
||||||
keep_flavor.external_id = inherited_external_id
|
|
||||||
keep_flavor.save(update_fields=["external_id"])
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from rest_framework.request import Request
|
|
||||||
|
|
||||||
from api.models import User, UserIP
|
|
||||||
|
|
||||||
|
|
||||||
def log_user_ip(user: User, request: Request) -> None:
|
|
||||||
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
|
||||||
if x_forwarded_for:
|
|
||||||
ip = x_forwarded_for.split(",")[0].strip()
|
|
||||||
else:
|
|
||||||
ip = request.META.get("REMOTE_ADDR")
|
|
||||||
|
|
||||||
if ip and user.is_authenticated:
|
|
||||||
UserIP.objects.update_or_create(user=user, ip_address=ip)
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
from api.models import Notification, Rating, Reply, User
|
|
||||||
|
|
||||||
_MENTION_RE = re.compile(r"@(\w+)")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_mentions(
|
|
||||||
text: str,
|
|
||||||
actor: User,
|
|
||||||
rating: Rating | None = None,
|
|
||||||
reply: Reply | None = None,
|
|
||||||
) -> None:
|
|
||||||
if not text:
|
|
||||||
return
|
|
||||||
for username in set(_MENTION_RE.findall(text)):
|
|
||||||
try:
|
|
||||||
recipient = User.objects.get(username__iexact=username)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
continue
|
|
||||||
if recipient == actor:
|
|
||||||
continue
|
|
||||||
Notification.objects.get_or_create(
|
|
||||||
recipient=recipient,
|
|
||||||
actor=actor,
|
|
||||||
notification_type="mention",
|
|
||||||
rating=rating,
|
|
||||||
reply=reply,
|
|
||||||
)
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
from django.db.models import Q, QuerySet
|
|
||||||
|
|
||||||
from api.models import Flavor
|
|
||||||
|
|
||||||
CATEGORY_KEYWORDS: dict[str, str] = {
|
|
||||||
"iced tea": "iced-tea",
|
|
||||||
"eistee": "iced-tea",
|
|
||||||
"energy": "energy",
|
|
||||||
"hydration": "hydration",
|
|
||||||
"milkshake": "milkshake",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def extract_category_slug(query: str) -> tuple[str | None, str]:
|
|
||||||
lowered = query.lower().strip()
|
|
||||||
for keyword, slug in CATEGORY_KEYWORDS.items():
|
|
||||||
if keyword in lowered:
|
|
||||||
return slug, lowered.replace(keyword, "").strip()
|
|
||||||
return None, lowered
|
|
||||||
|
|
||||||
|
|
||||||
def filter_flavors_by_query(queryset: QuerySet[Flavor], query: str) -> QuerySet[Flavor]:
|
|
||||||
slug, remaining = extract_category_slug(query)
|
|
||||||
if slug:
|
|
||||||
queryset = queryset.filter(category__slug=slug)
|
|
||||||
if remaining:
|
|
||||||
word_query = Q()
|
|
||||||
for word in remaining.split():
|
|
||||||
if len(word) > 2:
|
|
||||||
word_query |= Q(name__icontains=word) | Q(description__icontains=word)
|
|
||||||
if word_query:
|
|
||||||
queryset = queryset.filter(word_query)
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
|
|
||||||
def score_relevance(name: str, query: str) -> int:
|
|
||||||
name_lower = name.lower()
|
|
||||||
if name_lower == query:
|
|
||||||
return 3
|
|
||||||
if name_lower.startswith(query):
|
|
||||||
return 2
|
|
||||||
if query in name_lower:
|
|
||||||
return 1
|
|
||||||
return 0
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
"""Celery tasks for Holy Flavors.
|
|
||||||
|
|
||||||
Each @shared_task wraps an existing management command so that the same
|
|
||||||
code path remains usable from the CLI for manual ops. The legacy ``Job``
|
|
||||||
rows are kept as an execution log: tasks update the matching row with
|
|
||||||
status/output/timestamps so the admin UI keeps working.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from io import StringIO
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from celery import shared_task
|
|
||||||
from celery.utils.log import get_task_logger
|
|
||||||
from django.core.mail import send_mail
|
|
||||||
from django.core.management import call_command
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
logger = get_task_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_command_job(job_name: str, command: str, *args: str) -> str:
|
|
||||||
"""Run a management command and mirror status/output into the Job row.
|
|
||||||
|
|
||||||
Scheduling (next_run) is owned by django_celery_beat.PeriodicTask; the Job
|
|
||||||
serializer derives next_run from it. This function only writes execution
|
|
||||||
state: status, last_run, last_output, error_message.
|
|
||||||
"""
|
|
||||||
from api.models import Job
|
|
||||||
|
|
||||||
job, _ = Job.objects.get_or_create(name=job_name)
|
|
||||||
job.status = "running"
|
|
||||||
job.last_run = timezone.now()
|
|
||||||
job.last_output = ""
|
|
||||||
job.error_message = ""
|
|
||||||
job.save(update_fields=["status", "last_run", "last_output", "error_message"])
|
|
||||||
|
|
||||||
out = StringIO()
|
|
||||||
try:
|
|
||||||
call_command(command, *args, stdout=out)
|
|
||||||
job.status = "completed"
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
job.status = "failed"
|
|
||||||
job.error_message = str(exc)
|
|
||||||
logger.exception("Job %s failed", job_name)
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
job.last_output = out.getvalue()
|
|
||||||
job.save(update_fields=["status", "last_output", "error_message"])
|
|
||||||
|
|
||||||
return job.last_output
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task(
|
|
||||||
name="api.sync_flavors",
|
|
||||||
autoretry_for=(Exception,),
|
|
||||||
retry_backoff=60,
|
|
||||||
retry_backoff_max=600,
|
|
||||||
retry_kwargs={"max_retries": 2},
|
|
||||||
)
|
|
||||||
def sync_flavors_task() -> str:
|
|
||||||
return _run_command_job("sync_flavors", "sync_flavors")
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name="api.cleanup_duplicates")
|
|
||||||
def cleanup_duplicates_task() -> str:
|
|
||||||
return _run_command_job("cleanup_duplicates", "cleanup_duplicates")
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name="api.backup_db")
|
|
||||||
def backup_db_task() -> str:
|
|
||||||
return _run_command_job("backup_db", "backup_db", "--full")
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name="api.seed_legacy")
|
|
||||||
def seed_legacy_task() -> str:
|
|
||||||
return _run_command_job("seed_legacy", "seed_legacy_flavors")
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name="api.seed_banners")
|
|
||||||
def seed_banners_task() -> str:
|
|
||||||
return _run_command_job("seed_banners", "seed_banners")
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task(
|
|
||||||
name="api.send_email",
|
|
||||||
autoretry_for=(Exception,),
|
|
||||||
retry_backoff=True,
|
|
||||||
retry_kwargs={"max_retries": 3},
|
|
||||||
)
|
|
||||||
def send_email_task(
|
|
||||||
subject: str,
|
|
||||||
message: str,
|
|
||||||
recipient_list: list[str],
|
|
||||||
from_email: str | None = None,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> int:
|
|
||||||
"""Async wrapper around django.core.mail.send_mail."""
|
|
||||||
return send_mail(
|
|
||||||
subject=subject,
|
|
||||||
message=message,
|
|
||||||
from_email=from_email,
|
|
||||||
recipient_list=recipient_list,
|
|
||||||
fail_silently=False,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
Whitespace-only changes.
@@ -1,60 +0,0 @@
|
|||||||
from django.conf import settings
|
|
||||||
from django.urls import reverse
|
|
||||||
from rest_framework import status
|
|
||||||
from rest_framework.test import APITestCase
|
|
||||||
|
|
||||||
from api.models import User
|
|
||||||
|
|
||||||
|
|
||||||
class JWTAuthTests(APITestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.username = "jwtuser"
|
|
||||||
self.password = "correct-horse-battery-staple"
|
|
||||||
self.user = User.objects.create_user(username=self.username, password=self.password)
|
|
||||||
|
|
||||||
def _login(self):
|
|
||||||
return self.client.post(
|
|
||||||
reverse("token_obtain_pair"),
|
|
||||||
{"username": self.username, "password": self.password},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_obtain_pair_sets_cookies(self) -> None:
|
|
||||||
response = self._login()
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(response.data, {"status": "ok"})
|
|
||||||
self.assertIn(settings.JWT_AUTH_COOKIE_ACCESS, response.cookies)
|
|
||||||
self.assertIn(settings.JWT_AUTH_COOKIE_REFRESH, response.cookies)
|
|
||||||
access = response.cookies[settings.JWT_AUTH_COOKIE_ACCESS]
|
|
||||||
self.assertTrue(access["httponly"])
|
|
||||||
self.assertEqual(access["samesite"], settings.JWT_AUTH_COOKIE_SAMESITE)
|
|
||||||
|
|
||||||
def test_cookie_authenticates_protected_endpoint(self) -> None:
|
|
||||||
self._login()
|
|
||||||
# APIClient persists Set-Cookie across calls, so the next request is
|
|
||||||
# authenticated by the cookie alone.
|
|
||||||
response = self.client.get("/api/notifications/", follow=True)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
|
|
||||||
def test_refresh_uses_cookie_when_body_omitted(self) -> None:
|
|
||||||
self._login()
|
|
||||||
response = self.client.post(reverse("token_refresh"), format="json")
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(response.data, {"status": "ok"})
|
|
||||||
self.assertIn(settings.JWT_AUTH_COOKIE_ACCESS, response.cookies)
|
|
||||||
|
|
||||||
def test_invalid_refresh_is_rejected(self) -> None:
|
|
||||||
response = self.client.post(
|
|
||||||
reverse("token_refresh"),
|
|
||||||
{"refresh": "not-a-real-token"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
|
||||||
|
|
||||||
def test_logout_clears_cookies_and_blacklists_refresh(self) -> None:
|
|
||||||
self._login()
|
|
||||||
response = self.client.post(reverse("auth_logout"), format="json")
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
# set-cookie should be present with empty/expired value to clear them.
|
|
||||||
self.assertEqual(response.cookies[settings.JWT_AUTH_COOKIE_ACCESS].value, "")
|
|
||||||
self.assertEqual(response.cookies[settings.JWT_AUTH_COOKIE_REFRESH].value, "")
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
from django.urls import reverse
|
|
||||||
from rest_framework import status
|
|
||||||
from rest_framework.test import APITestCase
|
|
||||||
|
|
||||||
from api.models import Category, Flavor, Rating, User
|
|
||||||
|
|
||||||
|
|
||||||
class FlavorTopTests(APITestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.category_energy = Category.objects.create(name="Energy", slug="energy")
|
|
||||||
self.category_iced_tea = Category.objects.create(name="Iced Tea", slug="iced-tea")
|
|
||||||
|
|
||||||
self.user = User.objects.create_user(username="testuser", password="password")
|
|
||||||
|
|
||||||
self.flavor1 = Flavor.objects.create(name="Energy 1", category=self.category_energy)
|
|
||||||
self.flavor2 = Flavor.objects.create(name="Iced Tea 1", category=self.category_iced_tea)
|
|
||||||
|
|
||||||
Rating.objects.create(user=self.user, flavor=self.flavor1, score=5)
|
|
||||||
Rating.objects.create(user=self.user, flavor=self.flavor2, score=4)
|
|
||||||
|
|
||||||
def test_top_flavors_all(self) -> None:
|
|
||||||
url = reverse("flavor-top")
|
|
||||||
response = self.client.get(url, follow=True)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(len(response.data), 2)
|
|
||||||
|
|
||||||
def test_top_flavors_filtered_energy(self) -> None:
|
|
||||||
url = reverse("flavor-top")
|
|
||||||
response = self.client.get(url, {"category": "energy"}, follow=True)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(len(response.data), 1)
|
|
||||||
self.assertEqual(response.data[0]["name"], "Energy 1")
|
|
||||||
|
|
||||||
def test_top_flavors_filtered_iced_tea(self) -> None:
|
|
||||||
url = reverse("flavor-top")
|
|
||||||
response = self.client.get(url, {"category": "iced-tea"}, follow=True)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(len(response.data), 1)
|
|
||||||
self.assertEqual(response.data[0]["name"], "Iced Tea 1")
|
|
||||||
|
|
||||||
def test_top_flavors_filtered_non_existent(self) -> None:
|
|
||||||
url = reverse("flavor-top")
|
|
||||||
response = self.client.get(url, {"category": "non-existent"}, follow=True)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(len(response.data), 0)
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
from io import StringIO
|
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from api.management.commands.cleanup_duplicates import Command
|
|
||||||
from api.models import Category, Flavor, Notification, Rating, Reply, User
|
|
||||||
from api.services.flavor import merge_flavors
|
|
||||||
|
|
||||||
|
|
||||||
class FlavorMergeTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.cat = Category.objects.create(name="Energy", slug="energy")
|
|
||||||
self.user1 = User.objects.create_user(username="user1")
|
|
||||||
self.user2 = User.objects.create_user(username="user2")
|
|
||||||
|
|
||||||
self.keep = Flavor.objects.create(name="Dolphin Daiquiri", category=self.cat)
|
|
||||||
self.remove = Flavor.objects.create(name="Dolphin Daiquiri Energy", category=self.cat)
|
|
||||||
|
|
||||||
def test_basic_merge(self):
|
|
||||||
# User 1 rates 'remove'
|
|
||||||
r1 = Rating.objects.create(user=self.user1, flavor=self.remove, score=8, comment="Good!")
|
|
||||||
|
|
||||||
merge_flavors(self.keep, self.remove)
|
|
||||||
|
|
||||||
self.assertFalse(Flavor.objects.filter(id=self.remove.id).exists())
|
|
||||||
r1.refresh_from_db()
|
|
||||||
self.assertEqual(r1.flavor, self.keep)
|
|
||||||
|
|
||||||
def test_merge_with_conflict_keeps_longest_comment(self):
|
|
||||||
# User 1 rates both
|
|
||||||
Rating.objects.create(user=self.user1, flavor=self.keep, score=5, comment="Short")
|
|
||||||
r_long = Rating.objects.create(
|
|
||||||
user=self.user1, flavor=self.remove, score=9, comment="Very long and detailed review"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add a reply to the one being deleted
|
|
||||||
reply = Reply.objects.create(user=self.user2, rating=r_long, text="Nice review!")
|
|
||||||
|
|
||||||
merge_flavors(self.keep, self.remove)
|
|
||||||
|
|
||||||
self.assertEqual(Rating.objects.filter(user=self.user1, flavor=self.keep).count(), 1)
|
|
||||||
kept_rating = Rating.objects.get(user=self.user1, flavor=self.keep)
|
|
||||||
self.assertEqual(kept_rating.comment, "Very long and detailed review")
|
|
||||||
self.assertEqual(kept_rating.score, 9)
|
|
||||||
|
|
||||||
# Verify reply moved
|
|
||||||
reply.refresh_from_db()
|
|
||||||
self.assertEqual(reply.rating, kept_rating)
|
|
||||||
|
|
||||||
def test_merge_with_conflict_preserves_replies_from_discarded(self):
|
|
||||||
Rating.objects.create(user=self.user1, flavor=self.keep, score=10, comment="Best ever!")
|
|
||||||
r_remove = Rating.objects.create(
|
|
||||||
user=self.user1, flavor=self.remove, score=8, comment="Meh"
|
|
||||||
)
|
|
||||||
|
|
||||||
reply_on_remove = Reply.objects.create(user=self.user2, rating=r_remove, text="I disagree")
|
|
||||||
|
|
||||||
merge_flavors(self.keep, self.remove)
|
|
||||||
|
|
||||||
kept_rating = Rating.objects.get(user=self.user1, flavor=self.keep)
|
|
||||||
self.assertEqual(kept_rating.comment, "Best ever!")
|
|
||||||
|
|
||||||
reply_on_remove.refresh_from_db()
|
|
||||||
self.assertEqual(reply_on_remove.rating, kept_rating)
|
|
||||||
|
|
||||||
def test_merge_conflict_repoints_notifications(self):
|
|
||||||
# User 1 rated both; the discarded rating has a notification attached.
|
|
||||||
Rating.objects.create(user=self.user1, flavor=self.keep, score=10, comment="Best ever!")
|
|
||||||
r_remove = Rating.objects.create(
|
|
||||||
user=self.user1, flavor=self.remove, score=8, comment="Meh"
|
|
||||||
)
|
|
||||||
|
|
||||||
notif = Notification.objects.create(
|
|
||||||
recipient=self.user1,
|
|
||||||
actor=self.user2,
|
|
||||||
notification_type="reply",
|
|
||||||
rating=r_remove,
|
|
||||||
)
|
|
||||||
|
|
||||||
merge_flavors(self.keep, self.remove)
|
|
||||||
|
|
||||||
kept_rating = Rating.objects.get(user=self.user1, flavor=self.keep)
|
|
||||||
notif.refresh_from_db()
|
|
||||||
self.assertEqual(notif.rating, kept_rating)
|
|
||||||
|
|
||||||
def test_merge_transfers_external_id_when_keep_lacks_one(self):
|
|
||||||
self.assertIsNone(self.keep.external_id)
|
|
||||||
self.remove.external_id = 99887766
|
|
||||||
self.remove.save(update_fields=["external_id"])
|
|
||||||
|
|
||||||
merge_flavors(self.keep, self.remove)
|
|
||||||
|
|
||||||
self.keep.refresh_from_db()
|
|
||||||
self.assertEqual(self.keep.external_id, 99887766)
|
|
||||||
|
|
||||||
|
|
||||||
class CleanupDuplicatesTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.cat = Category.objects.create(name="Energy", slug="energy")
|
|
||||||
self.other_cat = Category.objects.create(name="Iced Tea", slug="iced-tea")
|
|
||||||
|
|
||||||
def test_normalization_logic(self):
|
|
||||||
f1 = Flavor.objects.create(name="Dolphin Daiquiri", category=self.cat)
|
|
||||||
f2 = Flavor.objects.create(name="Dolphin Daiquiri Energy", category=self.cat)
|
|
||||||
f3 = Flavor.objects.create(name="Dolphin Daiquiri", category=self.other_cat)
|
|
||||||
|
|
||||||
out = StringIO()
|
|
||||||
cmd = Command()
|
|
||||||
cmd.stdout = out
|
|
||||||
cmd.handle()
|
|
||||||
|
|
||||||
self.assertTrue(Flavor.objects.filter(id=f1.id).exists())
|
|
||||||
self.assertFalse(Flavor.objects.filter(id=f2.id).exists())
|
|
||||||
self.assertTrue(Flavor.objects.filter(id=f3.id).exists()) # Different category
|
|
||||||
|
|
||||||
def test_various_suffixes(self):
|
|
||||||
Flavor.objects.create(name="Berry", category=self.cat)
|
|
||||||
Flavor.objects.create(name="Berry Energy", category=self.cat)
|
|
||||||
Flavor.objects.create(name="Berry Hydration", category=self.cat)
|
|
||||||
Flavor.objects.create(name="Berry Iced Tea", category=self.cat)
|
|
||||||
Flavor.objects.create(name="Berry (Legacy)", category=self.cat)
|
|
||||||
|
|
||||||
cmd = Command()
|
|
||||||
cmd.stdout = StringIO()
|
|
||||||
cmd.handle()
|
|
||||||
|
|
||||||
self.assertEqual(Flavor.objects.filter(category=self.cat).count(), 1)
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from api.models import Category, Flavor, Rating, User
|
|
||||||
|
|
||||||
|
|
||||||
class FlavorAverageRatingTests(TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.category = Category.objects.create(name="Energy", slug="energy")
|
|
||||||
self.flavor = Flavor.objects.create(name="Test", category=self.category)
|
|
||||||
self.u1 = User.objects.create_user(username="u1", password="x")
|
|
||||||
self.u2 = User.objects.create_user(username="u2", password="x")
|
|
||||||
|
|
||||||
def test_average_zero_when_no_ratings(self) -> None:
|
|
||||||
self.assertEqual(self.flavor.get_average_rating, 0.0)
|
|
||||||
|
|
||||||
def test_average_uses_aggregate(self) -> None:
|
|
||||||
Rating.objects.create(user=self.u1, flavor=self.flavor, score=4)
|
|
||||||
Rating.objects.create(user=self.u2, flavor=self.flavor, score=8)
|
|
||||||
self.assertEqual(self.flavor.get_average_rating, 6.0)
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from django.core.management import call_command
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from api.models import Category, Flavor, Rating, User
|
|
||||||
|
|
||||||
|
|
||||||
def _product(pid, title, product_type="", tags=None):
|
|
||||||
return {"id": pid, "title": title, "product_type": product_type, "tags": tags or []}
|
|
||||||
|
|
||||||
|
|
||||||
class RecategorizeTests(TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.packs = Category.objects.create(name="Packs and other", slug="packs-and-other")
|
|
||||||
self.energy = Category.objects.create(name="Energy", slug="energy")
|
|
||||||
|
|
||||||
def _run(self, products, **opts):
|
|
||||||
with patch(
|
|
||||||
"api.management.commands.recategorize_flavors.Command.fetch_products",
|
|
||||||
return_value=products,
|
|
||||||
):
|
|
||||||
call_command("recategorize_flavors", **opts)
|
|
||||||
|
|
||||||
def test_dry_run_does_not_move(self) -> None:
|
|
||||||
# Mis-shelved: a real syrup product currently parked in packs.
|
|
||||||
flavor = Flavor.objects.create(name="Tropical Punch", category=self.packs, external_id=42)
|
|
||||||
self._run([_product(42, "Tropical Punch", "42 - Syrup Bottle")])
|
|
||||||
flavor.refresh_from_db()
|
|
||||||
self.assertEqual(flavor.category_id, self.packs.id)
|
|
||||||
self.assertFalse(Category.objects.filter(slug="syrup").exists())
|
|
||||||
|
|
||||||
def test_apply_moves_and_creates_category(self) -> None:
|
|
||||||
flavor = Flavor.objects.create(name="Tropical Punch", category=self.packs, external_id=42)
|
|
||||||
self._run([_product(42, "Tropical Punch", "42 - Syrup Bottle")], apply=True)
|
|
||||||
flavor.refresh_from_db()
|
|
||||||
self.assertEqual(flavor.category.slug, "syrup")
|
|
||||||
|
|
||||||
def test_accessory_moves_out_of_drink_category(self) -> None:
|
|
||||||
# The original bug: a cleaning brush synced into Syrup. Recategorize
|
|
||||||
# must shelve it under packs, not leave it as a drink.
|
|
||||||
syrup = Category.objects.create(name="Syrup", slug="syrup")
|
|
||||||
flavor = Flavor.objects.create(
|
|
||||||
name="Reinigungsbürste – Syrup Bottle", category=syrup, external_id=42
|
|
||||||
)
|
|
||||||
self._run(
|
|
||||||
[_product(42, "Reinigungsbürste – Syrup Bottle", "42 - Syrup Bottle")], apply=True
|
|
||||||
)
|
|
||||||
flavor.refresh_from_db()
|
|
||||||
self.assertEqual(flavor.category_id, self.packs.id)
|
|
||||||
|
|
||||||
def test_unchanged_row_stays(self) -> None:
|
|
||||||
flavor = Flavor.objects.create(name="Mojito Macaw", category=self.energy, external_id=1)
|
|
||||||
self._run([_product(1, "Mojito Macaw", "01 - Energy Bundle")], apply=True)
|
|
||||||
flavor.refresh_from_db()
|
|
||||||
self.assertEqual(flavor.category_id, self.energy.id)
|
|
||||||
|
|
||||||
def test_row_not_in_catalog_is_skipped(self) -> None:
|
|
||||||
flavor = Flavor.objects.create(name="Gone", category=self.packs, external_id=999)
|
|
||||||
self._run([_product(1, "Mojito Macaw", "01 - Energy Bundle")], apply=True)
|
|
||||||
flavor.refresh_from_db()
|
|
||||||
self.assertEqual(flavor.category_id, self.packs.id)
|
|
||||||
|
|
||||||
def test_collision_skipped_without_flag(self) -> None:
|
|
||||||
syrup = Category.objects.create(name="Syrup", slug="syrup")
|
|
||||||
# Target already holds a row of the same name.
|
|
||||||
Flavor.objects.create(name="Dup", category=syrup, external_id=100)
|
|
||||||
moving = Flavor.objects.create(name="Dup", category=self.packs, external_id=42)
|
|
||||||
self._run([_product(42, "Dup", "42 - Syrup Bottle")], apply=True)
|
|
||||||
moving.refresh_from_db()
|
|
||||||
self.assertEqual(moving.category_id, self.packs.id) # untouched
|
|
||||||
self.assertEqual(Flavor.objects.filter(name="Dup").count(), 2)
|
|
||||||
|
|
||||||
def test_collision_merges_with_flag(self) -> None:
|
|
||||||
user = User.objects.create_user(username="u", password="p")
|
|
||||||
syrup = Category.objects.create(name="Syrup", slug="syrup")
|
|
||||||
keep = Flavor.objects.create(name="Dup", category=syrup, external_id=100)
|
|
||||||
moving = Flavor.objects.create(name="Dup", category=self.packs, external_id=42)
|
|
||||||
Rating.objects.create(user=user, flavor=moving, score=5)
|
|
||||||
self._run([_product(42, "Dup", "42 - Syrup Bottle")], apply=True, merge_collisions=True)
|
|
||||||
self.assertFalse(Flavor.objects.filter(pk=moving.pk).exists())
|
|
||||||
self.assertTrue(Rating.objects.filter(flavor=keep, user=user).exists())
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from api.management.commands.sync_flavors import Command, _family_from_product_type
|
|
||||||
from api.models import Category, Flavor
|
|
||||||
|
|
||||||
|
|
||||||
class FamilyFromProductTypeTests(TestCase):
|
|
||||||
def test_strips_prefix_and_form_word(self) -> None:
|
|
||||||
self.assertEqual(_family_from_product_type("42 - Syrup Bottle"), "Syrup")
|
|
||||||
self.assertEqual(_family_from_product_type("01 - Energy Bundle"), "Energy")
|
|
||||||
self.assertEqual(_family_from_product_type("04 - Energy Sachet"), "Energy")
|
|
||||||
|
|
||||||
def test_multiword_family(self) -> None:
|
|
||||||
self.assertEqual(_family_from_product_type("02 - Iced Tea Bundle"), "Iced Tea")
|
|
||||||
|
|
||||||
def test_non_flavor_families_return_none(self) -> None:
|
|
||||||
for pt in ("10 - Shaker", "11 - Merch", "12 - Value Pack", "16 - Packaging Material"):
|
|
||||||
self.assertIsNone(_family_from_product_type(pt), pt)
|
|
||||||
|
|
||||||
def test_empty_or_missing(self) -> None:
|
|
||||||
self.assertIsNone(_family_from_product_type(""))
|
|
||||||
self.assertIsNone(_family_from_product_type("99 - "))
|
|
||||||
|
|
||||||
|
|
||||||
class ResolveCategoryTests(TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.cmd = Command()
|
|
||||||
self.packs = Category.objects.create(name="Packs and other", slug="packs-and-other")
|
|
||||||
|
|
||||||
def _resolve(self, product, title="", tags=None):
|
|
||||||
tags = tags or []
|
|
||||||
return self.cmd.resolve_category(product, title.lower(), tags, self.packs)
|
|
||||||
|
|
||||||
def test_new_family_auto_creates_category(self) -> None:
|
|
||||||
cat = self._resolve({"product_type": "42 - Syrup Bottle"}, title="Tropical Punch")
|
|
||||||
self.assertIsNotNone(cat)
|
|
||||||
self.assertEqual(cat.slug, "syrup")
|
|
||||||
self.assertTrue(Category.objects.filter(slug="syrup").exists())
|
|
||||||
|
|
||||||
def test_accessory_title_routes_to_packs(self) -> None:
|
|
||||||
# Cleaning brush carries product_type "42 - Syrup Bottle" but is not a drink.
|
|
||||||
for title in ("Reinigungsbürste – Syrup Bottle", "Ersatzteile – Syrup Bottle", "Zubehör"):
|
|
||||||
cat = self._resolve({"product_type": "42 - Syrup Bottle"}, title=title)
|
|
||||||
self.assertEqual(cat.id, self.packs.id, title)
|
|
||||||
|
|
||||||
def test_known_family_reuses_existing_slug(self) -> None:
|
|
||||||
existing = Category.objects.create(name="Iced Tea", slug="iced-tea")
|
|
||||||
cat = self._resolve({"product_type": "02 - Iced Tea Bundle"}, title="Erdbeere")
|
|
||||||
self.assertEqual(cat.id, existing.id)
|
|
||||||
|
|
||||||
def test_pack_title_keyword_wins_over_family(self) -> None:
|
|
||||||
cat = self._resolve({"product_type": "43 - Syrup Bundle"}, title="3er Box Syrup")
|
|
||||||
self.assertEqual(cat.id, self.packs.id)
|
|
||||||
|
|
||||||
def test_tag_fallback_when_no_product_type(self) -> None:
|
|
||||||
cat = self._resolve({}, title="Mojito Macaw", tags=["holy energy"])
|
|
||||||
self.assertEqual(cat.slug, "energy")
|
|
||||||
|
|
||||||
def test_no_signal_returns_none(self) -> None:
|
|
||||||
self.assertIsNone(self._resolve({"product_type": "20 - Other Items"}, title="Mystery"))
|
|
||||||
|
|
||||||
|
|
||||||
class SyrupVariantTests(TestCase):
|
|
||||||
def _bundle(self, variants=None):
|
|
||||||
return {
|
|
||||||
"product_type": "43 - Syrup Bundle",
|
|
||||||
"handle": "3er-box-syrup",
|
|
||||||
"body_html": "<p>Syrup desc</p>",
|
|
||||||
"options": [{"name": "Flavor"}],
|
|
||||||
"variants": variants
|
|
||||||
or [
|
|
||||||
{
|
|
||||||
"id": 111,
|
|
||||||
"option1": "Strawberry",
|
|
||||||
"available": True,
|
|
||||||
"featured_image": {"src": "https://cdn/straw.webp"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 222,
|
|
||||||
"option1": "Mango",
|
|
||||||
"available": False,
|
|
||||||
"featured_image": {"src": "https://cdn/mango.webp"},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_detects_syrup_bundle(self) -> None:
|
|
||||||
self.assertTrue(Command._is_syrup_variant_product(self._bundle()))
|
|
||||||
|
|
||||||
def test_cleaning_brush_not_expanded(self) -> None:
|
|
||||||
# Same Syrup family, but no Flavor option and a single variant.
|
|
||||||
brush = {
|
|
||||||
"product_type": "42 - Syrup Bottle",
|
|
||||||
"options": [{"name": "Title"}],
|
|
||||||
"variants": [{"id": 1, "option1": "Default Title"}],
|
|
||||||
}
|
|
||||||
self.assertFalse(Command._is_syrup_variant_product(brush))
|
|
||||||
|
|
||||||
def test_other_lines_with_flavor_option_not_expanded(self) -> None:
|
|
||||||
# Energy probe also has a Flavor option, but those flavors are standalone products.
|
|
||||||
probe = {
|
|
||||||
"product_type": "04 - Energy Sachet",
|
|
||||||
"options": [{"name": "Flavor"}],
|
|
||||||
"variants": [{"id": 1, "option1": "Mojito Macaw"}, {"id": 2, "option1": "Cherry"}],
|
|
||||||
}
|
|
||||||
self.assertFalse(Command._is_syrup_variant_product(probe))
|
|
||||||
|
|
||||||
def test_expands_variants_into_individual_flavors(self) -> None:
|
|
||||||
synced: list[int] = []
|
|
||||||
with patch.object(Command, "download_gallery", return_value=[]):
|
|
||||||
created, updated = Command()._sync_syrup_variants(self._bundle(), synced)
|
|
||||||
|
|
||||||
self.assertEqual((created, updated), (2, 0))
|
|
||||||
syrup = Category.objects.get(slug="syrup")
|
|
||||||
self.assertEqual(
|
|
||||||
set(Flavor.objects.filter(category=syrup).values_list("name", flat=True)),
|
|
||||||
{"Strawberry", "Mango"},
|
|
||||||
)
|
|
||||||
straw = Flavor.objects.get(name="Strawberry")
|
|
||||||
self.assertEqual(straw.external_id, 111)
|
|
||||||
self.assertTrue(straw.is_available)
|
|
||||||
self.assertEqual(straw.image_url, "https://cdn/straw.webp")
|
|
||||||
self.assertEqual(straw.shop_url, "https://weareholy.com/products/3er-box-syrup")
|
|
||||||
self.assertFalse(Flavor.objects.get(name="Mango").is_available)
|
|
||||||
self.assertEqual(synced, [111, 222])
|
|
||||||
|
|
||||||
def test_re_sync_updates_not_duplicates(self) -> None:
|
|
||||||
with patch.object(Command, "download_gallery", return_value=[]):
|
|
||||||
Command()._sync_syrup_variants(self._bundle(), [])
|
|
||||||
created, updated = Command()._sync_syrup_variants(self._bundle(), [])
|
|
||||||
self.assertEqual((created, updated), (0, 2))
|
|
||||||
self.assertEqual(Flavor.objects.filter(category__slug="syrup").count(), 2)
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
from django.urls import include, path
|
|
||||||
from rest_framework import routers
|
|
||||||
|
|
||||||
from . import views
|
|
||||||
|
|
||||||
router = routers.DefaultRouter()
|
|
||||||
router.register("users", views.UserViewSet)
|
|
||||||
router.register("flavors", views.FlavorViewSet)
|
|
||||||
router.register("categories", views.CategoryViewSet)
|
|
||||||
router.register("ratings", views.RatingViewSet)
|
|
||||||
router.register("replies", views.ReplyViewSet)
|
|
||||||
router.register("notifications", views.NotificationViewSet, basename="notification")
|
|
||||||
router.register("tickets", views.TicketViewSet, basename="ticket")
|
|
||||||
router.register("admin-custom", views.AdminViewSet, basename="admin-custom")
|
|
||||||
router.register("banners", views.BannerViewSet, basename="banner")
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path("", include(router.urls)),
|
|
||||||
]
|
|
||||||
Whitespace-only changes.
@@ -1,26 +0,0 @@
|
|||||||
"""Auth helpers for narrowing request.user to the concrete User type.
|
|
||||||
|
|
||||||
DRF's `request.user` is typed as `User | AnonymousUser`. After a view enforces
|
|
||||||
`IsAuthenticated`, the union is misleading: every code path past the permission
|
|
||||||
check has the concrete user. These helpers express that narrowing for mypy and
|
|
||||||
keep view bodies readable.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from rest_framework.exceptions import NotAuthenticated
|
|
||||||
from rest_framework.request import Request
|
|
||||||
|
|
||||||
from api.models import User
|
|
||||||
|
|
||||||
|
|
||||||
def current_user(request: Request) -> User:
|
|
||||||
"""Return the authenticated User for `request` or raise 401.
|
|
||||||
|
|
||||||
Use after `IsAuthenticated` permission. The check is defensive — the
|
|
||||||
permission class already rejected anonymous calls.
|
|
||||||
"""
|
|
||||||
user = request.user
|
|
||||||
if not isinstance(user, User):
|
|
||||||
raise NotAuthenticated
|
|
||||||
return user
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
"""httpOnly-cookie JWT auth.
|
|
||||||
|
|
||||||
Reads `access_token` from a cookie set by the login/refresh endpoints. Falls
|
|
||||||
back to the Authorization header for backwards compatibility during rollout —
|
|
||||||
strip the fallback once all clients use cookies.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from drf_spectacular.extensions import OpenApiAuthenticationExtension
|
|
||||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
|
||||||
|
|
||||||
|
|
||||||
class CookieJWTAuthentication(JWTAuthentication):
|
|
||||||
def authenticate(self, request):
|
|
||||||
raw_token = request.COOKIES.get(settings.JWT_AUTH_COOKIE_ACCESS)
|
|
||||||
if raw_token is None:
|
|
||||||
return super().authenticate(request)
|
|
||||||
validated_token = self.get_validated_token(raw_token)
|
|
||||||
return self.get_user(validated_token), validated_token
|
|
||||||
|
|
||||||
|
|
||||||
class CookieJWTScheme(OpenApiAuthenticationExtension):
|
|
||||||
"""Make drf-spectacular treat CookieJWTAuthentication like the SimpleJWT scheme."""
|
|
||||||
|
|
||||||
target_class = "api.utils.cookie_auth.CookieJWTAuthentication"
|
|
||||||
name = "cookieAuth"
|
|
||||||
|
|
||||||
def get_security_definition(self, auto_schema): # type: ignore[no-untyped-def]
|
|
||||||
return {
|
|
||||||
"type": "apiKey",
|
|
||||||
"in": "cookie",
|
|
||||||
"name": settings.JWT_AUTH_COOKIE_ACCESS,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def set_jwt_cookies(response, access: str | None, refresh: str | None) -> None:
|
|
||||||
"""Attach access/refresh tokens to the response as httpOnly cookies."""
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
secure = settings.JWT_AUTH_COOKIE_SECURE
|
|
||||||
samesite = settings.JWT_AUTH_COOKIE_SAMESITE
|
|
||||||
path = settings.JWT_AUTH_COOKIE_PATH
|
|
||||||
|
|
||||||
if access:
|
|
||||||
access_lifetime: timedelta = settings.SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"]
|
|
||||||
response.set_cookie(
|
|
||||||
settings.JWT_AUTH_COOKIE_ACCESS,
|
|
||||||
access,
|
|
||||||
max_age=int(access_lifetime.total_seconds()),
|
|
||||||
httponly=True,
|
|
||||||
secure=secure,
|
|
||||||
samesite=samesite,
|
|
||||||
path=path,
|
|
||||||
)
|
|
||||||
if refresh:
|
|
||||||
refresh_lifetime: timedelta = settings.SIMPLE_JWT["REFRESH_TOKEN_LIFETIME"]
|
|
||||||
response.set_cookie(
|
|
||||||
settings.JWT_AUTH_COOKIE_REFRESH,
|
|
||||||
refresh,
|
|
||||||
max_age=int(refresh_lifetime.total_seconds()),
|
|
||||||
httponly=True,
|
|
||||||
secure=secure,
|
|
||||||
samesite=samesite,
|
|
||||||
path=path,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_jwt_cookies(response) -> None:
|
|
||||||
response.delete_cookie(settings.JWT_AUTH_COOKIE_ACCESS, path=settings.JWT_AUTH_COOKIE_PATH)
|
|
||||||
response.delete_cookie(settings.JWT_AUTH_COOKIE_REFRESH, path=settings.JWT_AUTH_COOKIE_PATH)
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
from api.views._pagination import FeedPagination
|
|
||||||
from api.views.admin import AdminViewSet
|
|
||||||
from api.views.banner import BannerViewSet
|
|
||||||
from api.views.flavor import CategoryViewSet, FlavorViewSet
|
|
||||||
from api.views.rating import RatingViewSet, ReplyViewSet
|
|
||||||
from api.views.support import TicketViewSet
|
|
||||||
from api.views.user import NotificationViewSet, UserViewSet
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"AdminViewSet",
|
|
||||||
"BannerViewSet",
|
|
||||||
"CategoryViewSet",
|
|
||||||
"FeedPagination",
|
|
||||||
"FlavorViewSet",
|
|
||||||
"NotificationViewSet",
|
|
||||||
"RatingViewSet",
|
|
||||||
"ReplyViewSet",
|
|
||||||
"TicketViewSet",
|
|
||||||
"UserViewSet",
|
|
||||||
]
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from rest_framework.pagination import PageNumberPagination
|
|
||||||
|
|
||||||
|
|
||||||
class FeedPagination(PageNumberPagination):
|
|
||||||
page_size = 10
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
import json
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.mail import send_mail
|
|
||||||
from rest_framework import permissions, status, viewsets
|
|
||||||
from rest_framework.decorators import action
|
|
||||||
from rest_framework.request import Request
|
|
||||||
from rest_framework.response import Response
|
|
||||||
|
|
||||||
from api.models import Flavor, Job, Rating, Reply, SystemConfig, Ticket, User
|
|
||||||
from api.services.flavor import merge_flavors
|
|
||||||
from api.serializers import (
|
|
||||||
AdminUserDetailSerializer,
|
|
||||||
AdminUserListSerializer,
|
|
||||||
JobSerializer,
|
|
||||||
SystemConfigSerializer,
|
|
||||||
)
|
|
||||||
from api.tasks import (
|
|
||||||
backup_db_task,
|
|
||||||
cleanup_duplicates_task,
|
|
||||||
seed_banners_task,
|
|
||||||
seed_legacy_task,
|
|
||||||
sync_flavors_task,
|
|
||||||
)
|
|
||||||
from api.utils.auth import current_user
|
|
||||||
|
|
||||||
# Maps Job.name → (celery task name for beat schedules, callable for direct dispatch).
|
|
||||||
# Direct callables let .delay() honor CELERY_TASK_ALWAYS_EAGER in dev.
|
|
||||||
JOB_TASKS: dict[str, tuple[str, Any]] = {
|
|
||||||
"sync_flavors": ("api.sync_flavors", sync_flavors_task),
|
|
||||||
"cleanup_duplicates": ("api.cleanup_duplicates", cleanup_duplicates_task),
|
|
||||||
"backup_db": ("api.backup_db", backup_db_task),
|
|
||||||
"seed_legacy": ("api.seed_legacy", seed_legacy_task),
|
|
||||||
"seed_banners": ("api.seed_banners", seed_banners_task),
|
|
||||||
}
|
|
||||||
|
|
||||||
JOB_TASK_MAP = {name: meta[0] for name, meta in JOB_TASKS.items()}
|
|
||||||
|
|
||||||
|
|
||||||
def _sync_periodic_task(job: Job) -> None:
|
|
||||||
"""Create/update a django_celery_beat PeriodicTask for this Job.
|
|
||||||
|
|
||||||
Called when the admin changes interval_hours. interval_hours == 0 disables
|
|
||||||
the schedule (PeriodicTask.enabled=False).
|
|
||||||
"""
|
|
||||||
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
||||||
|
|
||||||
task_name = JOB_TASK_MAP.get(job.name)
|
|
||||||
if not task_name:
|
|
||||||
return
|
|
||||||
|
|
||||||
periodic_name = f"job:{job.name}"
|
|
||||||
|
|
||||||
if job.interval_hours <= 0:
|
|
||||||
PeriodicTask.objects.filter(name=periodic_name).update(enabled=False)
|
|
||||||
return
|
|
||||||
|
|
||||||
schedule, _ = IntervalSchedule.objects.get_or_create(
|
|
||||||
every=job.interval_hours,
|
|
||||||
period=IntervalSchedule.HOURS,
|
|
||||||
)
|
|
||||||
PeriodicTask.objects.update_or_create(
|
|
||||||
name=periodic_name,
|
|
||||||
defaults={
|
|
||||||
"interval": schedule,
|
|
||||||
"task": task_name,
|
|
||||||
"enabled": True,
|
|
||||||
"kwargs": json.dumps({}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AdminViewSet(viewsets.ViewSet):
|
|
||||||
permission_classes = [permissions.IsAdminUser]
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"])
|
|
||||||
def stats(self, request: Request) -> Response:
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
"total_users": User.objects.count(),
|
|
||||||
"total_ratings": Rating.objects.count(),
|
|
||||||
"total_replies": Reply.objects.count(),
|
|
||||||
"open_tickets": Ticket.objects.filter(status="open").count(),
|
|
||||||
"email_config": {
|
|
||||||
"host": getattr(settings, "EMAIL_HOST", "None"),
|
|
||||||
"port": getattr(settings, "EMAIL_PORT", "None"),
|
|
||||||
"use_tls": getattr(settings, "EMAIL_USE_TLS", False),
|
|
||||||
"use_ssl": getattr(settings, "EMAIL_USE_SSL", False),
|
|
||||||
"skip_verify": getattr(settings, "EMAIL_SKIP_CERT_VERIFICATION", False),
|
|
||||||
},
|
|
||||||
"server_info": {
|
|
||||||
"debug": settings.DEBUG,
|
|
||||||
"allowed_hosts": settings.ALLOWED_HOSTS,
|
|
||||||
"frontend_url": getattr(settings, "FRONTEND_URL", "Not set"),
|
|
||||||
"media_root": str(settings.MEDIA_ROOT),
|
|
||||||
"static_root": str(settings.STATIC_ROOT),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get", "patch"])
|
|
||||||
def config(self, request: Request) -> Response:
|
|
||||||
config = SystemConfig.get_solo()
|
|
||||||
if request.method == "PATCH":
|
|
||||||
serializer = SystemConfigSerializer(config, data=request.data, partial=True)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
serializer.save()
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
return Response(SystemConfigSerializer(config).data)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"])
|
|
||||||
def jobs(self, request: Request) -> Response:
|
|
||||||
for job_type, _ in Job.JOB_TYPES:
|
|
||||||
Job.objects.get_or_create(name=job_type)
|
|
||||||
|
|
||||||
jobs = Job.objects.all().order_by("name")
|
|
||||||
return Response(JobSerializer(jobs, many=True).data)
|
|
||||||
|
|
||||||
@action(detail=True, methods=["post"])
|
|
||||||
def trigger_job(self, request: Request, pk=None) -> Response:
|
|
||||||
try:
|
|
||||||
job = Job.objects.get(pk=pk)
|
|
||||||
except Job.DoesNotExist:
|
|
||||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
meta = JOB_TASKS.get(job.name)
|
|
||||||
if not meta:
|
|
||||||
return Response(
|
|
||||||
{"error": f"No celery task registered for {job.name}"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
task_name, task_fn = meta
|
|
||||||
job.status = "pending"
|
|
||||||
job.error_message = ""
|
|
||||||
job.save(update_fields=["status", "error_message"])
|
|
||||||
|
|
||||||
task_fn.delay()
|
|
||||||
return Response({"status": "Job queued", "task": task_name})
|
|
||||||
|
|
||||||
@action(detail=True, methods=["patch"])
|
|
||||||
def update_job_schedule(self, request: Request, pk=None) -> Response:
|
|
||||||
try:
|
|
||||||
job = Job.objects.get(pk=pk)
|
|
||||||
except Job.DoesNotExist:
|
|
||||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
interval = request.data.get("interval_hours")
|
|
||||||
if interval is not None:
|
|
||||||
job.interval_hours = int(interval)
|
|
||||||
job.save(update_fields=["interval_hours"])
|
|
||||||
|
|
||||||
_sync_periodic_task(job)
|
|
||||||
return Response(JobSerializer(job).data)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["post"])
|
|
||||||
def merge_flavors(self, request: Request) -> Response:
|
|
||||||
keep_id = request.data.get("keep_id")
|
|
||||||
remove_id = request.data.get("remove_id")
|
|
||||||
|
|
||||||
if not keep_id or not remove_id:
|
|
||||||
return Response(
|
|
||||||
{"error": "Both keep_id and remove_id are required"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
keep_flavor = Flavor.objects.get(pk=keep_id)
|
|
||||||
remove_flavor = Flavor.objects.get(pk=remove_id)
|
|
||||||
except Flavor.DoesNotExist:
|
|
||||||
return Response(
|
|
||||||
{"error": "One or both flavors not found"}, status=status.HTTP_404_NOT_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
if keep_flavor.category_id != remove_flavor.category_id:
|
|
||||||
return Response(
|
|
||||||
{"error": "Flavors must belong to the same category"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
merge_flavors(keep_flavor, remove_flavor)
|
|
||||||
return Response({"status": "Flavors merged successfully"})
|
|
||||||
|
|
||||||
@action(detail=False, methods=["post"])
|
|
||||||
def send_test_email(self, request: Request) -> Response:
|
|
||||||
me = current_user(request)
|
|
||||||
try:
|
|
||||||
send_mail(
|
|
||||||
"Holy Flavors Admin Test Email",
|
|
||||||
(f"This is a test email sent to {me.email} from the Holy Flavors Admin Interface."),
|
|
||||||
settings.DEFAULT_FROM_EMAIL,
|
|
||||||
[me.email],
|
|
||||||
fail_silently=False,
|
|
||||||
)
|
|
||||||
return Response({"status": "Test email sent!"})
|
|
||||||
except Exception as e:
|
|
||||||
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"])
|
|
||||||
def users(self, request: Request) -> Response:
|
|
||||||
users = User.objects.all().prefetch_related("ips").order_by("-date_joined")
|
|
||||||
return Response(AdminUserListSerializer(users, many=True).data)
|
|
||||||
|
|
||||||
@action(detail=True, methods=["get", "patch", "delete"])
|
|
||||||
def user_detail(self, request: Request, pk=None) -> Response:
|
|
||||||
try:
|
|
||||||
user = User.objects.get(pk=pk)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
if request.method == "GET":
|
|
||||||
return Response(AdminUserDetailSerializer(user, context={"request": request}).data)
|
|
||||||
elif request.method == "PATCH":
|
|
||||||
is_active = request.data.get("is_active")
|
|
||||||
if is_active is not None:
|
|
||||||
user.is_active = is_active
|
|
||||||
user.save()
|
|
||||||
return Response(AdminUserDetailSerializer(user, context={"request": request}).data)
|
|
||||||
elif request.method == "DELETE":
|
|
||||||
user.delete()
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
||||||
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED)
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
"""Cookie-backed JWT auth views.
|
|
||||||
|
|
||||||
`CookieTokenObtainPairView` and `CookieTokenRefreshView` move the JWT pair into
|
|
||||||
httpOnly cookies on the response so the frontend never has tokens in JS-readable
|
|
||||||
storage. Refresh accepts the token from the cookie *or* the legacy request body.
|
|
||||||
`CookieLogoutView` clears both cookies and blacklists the refresh token.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from rest_framework import status
|
|
||||||
from rest_framework.permissions import AllowAny
|
|
||||||
from rest_framework.request import Request
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.views import APIView
|
|
||||||
from rest_framework_simplejwt.exceptions import TokenError
|
|
||||||
from rest_framework_simplejwt.tokens import RefreshToken
|
|
||||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
|
||||||
|
|
||||||
from api.utils.cookie_auth import clear_jwt_cookies, set_jwt_cookies
|
|
||||||
|
|
||||||
|
|
||||||
class CookieTokenObtainPairView(TokenObtainPairView):
|
|
||||||
def post(self, request: Request, *args, **kwargs):
|
|
||||||
response = super().post(request, *args, **kwargs)
|
|
||||||
if response.status_code == status.HTTP_200_OK:
|
|
||||||
access = response.data.pop("access", None)
|
|
||||||
refresh = response.data.pop("refresh", None)
|
|
||||||
set_jwt_cookies(response, access, refresh)
|
|
||||||
response.data = {"status": "ok"}
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
class CookieTokenRefreshView(TokenRefreshView):
|
|
||||||
def post(self, request: Request, *args, **kwargs):
|
|
||||||
if "refresh" not in request.data:
|
|
||||||
cookie_refresh = request.COOKIES.get(settings.JWT_AUTH_COOKIE_REFRESH)
|
|
||||||
if cookie_refresh:
|
|
||||||
# request.data is a QueryDict / dict — make a mutable copy.
|
|
||||||
data = request.data.copy() if hasattr(request.data, "copy") else dict(request.data)
|
|
||||||
data["refresh"] = cookie_refresh
|
|
||||||
request._full_data = data # type: ignore[attr-defined]
|
|
||||||
response = super().post(request, *args, **kwargs)
|
|
||||||
if response.status_code == status.HTTP_200_OK:
|
|
||||||
access = response.data.pop("access", None)
|
|
||||||
refresh = response.data.pop("refresh", None)
|
|
||||||
set_jwt_cookies(response, access, refresh)
|
|
||||||
response.data = {"status": "ok"}
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
class CookieLogoutView(APIView):
|
|
||||||
permission_classes = [AllowAny]
|
|
||||||
|
|
||||||
def post(self, request: Request) -> Response:
|
|
||||||
refresh = request.COOKIES.get(settings.JWT_AUTH_COOKIE_REFRESH)
|
|
||||||
if refresh:
|
|
||||||
try:
|
|
||||||
RefreshToken(refresh).blacklist()
|
|
||||||
except TokenError:
|
|
||||||
pass
|
|
||||||
response = Response({"status": "ok"})
|
|
||||||
clear_jwt_cookies(response)
|
|
||||||
return response
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
from rest_framework import permissions, status, viewsets
|
|
||||||
from rest_framework.decorators import action
|
|
||||||
from rest_framework.request import Request
|
|
||||||
from rest_framework.response import Response
|
|
||||||
|
|
||||||
from api.models import Banner, User
|
|
||||||
from api.serializers import BannerSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class BannerViewSet(viewsets.ModelViewSet):
|
|
||||||
queryset = Banner.objects.all()
|
|
||||||
serializer_class = BannerSerializer
|
|
||||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
|
||||||
pagination_class = None
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
if self.request.user.is_authenticated and self.request.user.is_superuser:
|
|
||||||
return Banner.objects.all()
|
|
||||||
return Banner.objects.filter(is_enabled=True)
|
|
||||||
|
|
||||||
def get_permissions(self):
|
|
||||||
if self.action in ["list", "retrieve", "active"]:
|
|
||||||
return [permissions.AllowAny()]
|
|
||||||
return [permissions.IsAdminUser()]
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"])
|
|
||||||
def active(self, request: Request) -> Response:
|
|
||||||
username = request.query_params.get("username")
|
|
||||||
user = None
|
|
||||||
if username:
|
|
||||||
try:
|
|
||||||
user = User.objects.get(username=username)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not user and request.user.is_authenticated:
|
|
||||||
user = request.user
|
|
||||||
|
|
||||||
if user and user.selected_banner and user.selected_banner.is_enabled:
|
|
||||||
return Response(self.get_serializer(user.selected_banner).data)
|
|
||||||
|
|
||||||
active_banner = Banner.objects.filter(is_active=True).first()
|
|
||||||
if active_banner:
|
|
||||||
return Response(self.get_serializer(active_banner).data)
|
|
||||||
return Response(None)
|
|
||||||
|
|
||||||
@action(detail=True, methods=["post"])
|
|
||||||
def activate(self, request: Request, pk=None) -> Response:
|
|
||||||
banner = self.get_object()
|
|
||||||
banner.is_active = True
|
|
||||||
banner.is_enabled = True
|
|
||||||
banner.save()
|
|
||||||
return Response({"status": "banner set as global default"})
|
|
||||||
|
|
||||||
@action(detail=True, methods=["post"])
|
|
||||||
def toggle_enabled(self, request: Request, pk=None) -> Response:
|
|
||||||
banner = self.get_object()
|
|
||||||
if banner.is_active:
|
|
||||||
return Response(
|
|
||||||
{"error": "Cannot disable the global default banner."},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
banner.is_enabled = not banner.is_enabled
|
|
||||||
banner.save()
|
|
||||||
return Response({"status": "enabled" if banner.is_enabled else "disabled"})
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
from django.db.models import Avg
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
|
||||||
from rest_framework import filters, permissions, viewsets
|
|
||||||
from rest_framework.decorators import action
|
|
||||||
from rest_framework.request import Request
|
|
||||||
from rest_framework.response import Response
|
|
||||||
|
|
||||||
from api.models import Category, Flavor
|
|
||||||
from api.serializers import CategorySerializer, FlavorSerializer
|
|
||||||
from api.services.search import (
|
|
||||||
extract_category_slug,
|
|
||||||
filter_flavors_by_query,
|
|
||||||
score_relevance,
|
|
||||||
)
|
|
||||||
from api.utils.auth import current_user
|
|
||||||
|
|
||||||
|
|
||||||
class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
|
|
||||||
queryset = Category.objects.all()
|
|
||||||
serializer_class = CategorySerializer
|
|
||||||
|
|
||||||
|
|
||||||
class FlavorViewSet(viewsets.ModelViewSet):
|
|
||||||
queryset = Flavor.objects.annotate(average_rating=Avg("ratings__score")).order_by(
|
|
||||||
"-average_rating"
|
|
||||||
)
|
|
||||||
serializer_class = FlavorSerializer
|
|
||||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
|
|
||||||
filterset_fields = ["category", "category__slug"]
|
|
||||||
search_fields = ["name", "description"]
|
|
||||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
qs = (
|
|
||||||
Flavor.objects.select_related("category")
|
|
||||||
.annotate(average_rating=Avg("ratings__score"))
|
|
||||||
.order_by("-average_rating")
|
|
||||||
.distinct()
|
|
||||||
)
|
|
||||||
search_query = self.request.query_params.get("search", "")
|
|
||||||
slug, _ = extract_category_slug(search_query)
|
|
||||||
if slug:
|
|
||||||
qs = qs.filter(category__slug=slug)
|
|
||||||
return qs
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"], permission_classes=[permissions.AllowAny])
|
|
||||||
def search(self, request: Request) -> Response:
|
|
||||||
query = request.query_params.get("q", "").lower().strip()
|
|
||||||
if not query:
|
|
||||||
return Response([])
|
|
||||||
|
|
||||||
flavors = Flavor.objects.select_related("category").distinct()
|
|
||||||
flavors = filter_flavors_by_query(flavors, query)
|
|
||||||
|
|
||||||
_, remaining = extract_category_slug(query)
|
|
||||||
seen_keys: set[str] = set()
|
|
||||||
results: list[dict] = []
|
|
||||||
|
|
||||||
for f in flavors.order_by("name"):
|
|
||||||
key = f"{f.name.strip().lower()}|{f.category.slug}"
|
|
||||||
if key in seen_keys:
|
|
||||||
continue
|
|
||||||
seen_keys.add(key)
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"id": f.id,
|
|
||||||
"name": f.name,
|
|
||||||
"type": "flavor",
|
|
||||||
"subtitle": f.category.name,
|
|
||||||
"image_url": (
|
|
||||||
request.build_absolute_uri(f.image.url) if f.image else f.image_url
|
|
||||||
),
|
|
||||||
"slug": None,
|
|
||||||
"relevance": score_relevance(f.name, remaining),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
results.sort(key=lambda x: (-x["relevance"], x["name"]))
|
|
||||||
for r in results:
|
|
||||||
del r["relevance"]
|
|
||||||
return Response(results[:15])
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"], permission_classes=[permissions.AllowAny])
|
|
||||||
def top(self, request: Request) -> Response:
|
|
||||||
queryset = self.get_queryset().filter(ratings__isnull=False)
|
|
||||||
category_slug = request.query_params.get("category")
|
|
||||||
if category_slug:
|
|
||||||
queryset = queryset.filter(category__slug=category_slug)
|
|
||||||
top_flavors = queryset.distinct()[:10]
|
|
||||||
return Response(self.get_serializer(top_flavors, many=True).data)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"], permission_classes=[permissions.AllowAny])
|
|
||||||
def newest(self, request: Request) -> Response:
|
|
||||||
newest_flavors = Flavor.objects.select_related("category").order_by("-created_at")[:10]
|
|
||||||
return Response(self.get_serializer(newest_flavors, many=True).data)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"], permission_classes=[permissions.IsAuthenticated])
|
|
||||||
def followed_top(self, request: Request) -> Response:
|
|
||||||
followed_users = current_user(request).following.all()
|
|
||||||
top_flavors = (
|
|
||||||
Flavor.objects.filter(ratings__user__in=followed_users)
|
|
||||||
.annotate(average_rating=Avg("ratings__score"))
|
|
||||||
.order_by("-average_rating")
|
|
||||||
.distinct()[:10]
|
|
||||||
)
|
|
||||||
return Response(self.get_serializer(top_flavors, many=True).data)
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
from django.utils.decorators import method_decorator
|
|
||||||
from django_ratelimit.decorators import ratelimit
|
|
||||||
from rest_framework import permissions, serializers, status, viewsets
|
|
||||||
from rest_framework.decorators import action
|
|
||||||
from rest_framework.exceptions import PermissionDenied
|
|
||||||
from rest_framework.request import Request
|
|
||||||
from rest_framework.response import Response
|
|
||||||
|
|
||||||
from api.models import Notification, Rating, Reply
|
|
||||||
from api.serializers import RatingSerializer, ReplySerializer
|
|
||||||
from api.services.mentions import parse_mentions
|
|
||||||
from api.utils.auth import current_user
|
|
||||||
from api.views._pagination import FeedPagination
|
|
||||||
|
|
||||||
|
|
||||||
class RatingViewSet(viewsets.ModelViewSet):
|
|
||||||
queryset = Rating.objects.all()
|
|
||||||
serializer_class = RatingSerializer
|
|
||||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
|
||||||
|
|
||||||
@method_decorator(ratelimit(key="user", rate="10/m", method="POST", block=True))
|
|
||||||
def create(self, request, *args, **kwargs):
|
|
||||||
return super().create(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
|
||||||
me = current_user(self.request)
|
|
||||||
flavor = serializer.validated_data["flavor"]
|
|
||||||
if Rating.objects.filter(user=me, flavor=flavor).exists():
|
|
||||||
raise serializers.ValidationError("You have already rated this flavor.")
|
|
||||||
rating = serializer.save(user=me)
|
|
||||||
if rating.comment:
|
|
||||||
parse_mentions(rating.comment, me, rating=rating)
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return (
|
|
||||||
Rating.objects.select_related("user", "flavor", "flavor__category")
|
|
||||||
.prefetch_related("replies", "replies__user")
|
|
||||||
.order_by("-created_at")
|
|
||||||
)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"], permission_classes=[permissions.IsAuthenticated])
|
|
||||||
def feed(self, request: Request) -> Response:
|
|
||||||
me = current_user(request)
|
|
||||||
followed_users = me.following.all()
|
|
||||||
feed_ratings = (
|
|
||||||
Rating.objects.filter(user__in=followed_users)
|
|
||||||
.select_related("user", "flavor", "flavor__category")
|
|
||||||
.prefetch_related("replies", "replies__user")
|
|
||||||
.order_by("-created_at")
|
|
||||||
)
|
|
||||||
|
|
||||||
paginator = FeedPagination()
|
|
||||||
page = paginator.paginate_queryset(feed_ratings, request)
|
|
||||||
if page is not None:
|
|
||||||
serializer = self.get_serializer(page, many=True)
|
|
||||||
return paginator.get_paginated_response(serializer.data)
|
|
||||||
|
|
||||||
return Response(self.get_serializer(feed_ratings, many=True).data)
|
|
||||||
|
|
||||||
def check_object_permissions(self, request, obj):
|
|
||||||
super().check_object_permissions(request, obj)
|
|
||||||
if (
|
|
||||||
request.method in ["PUT", "PATCH", "DELETE"]
|
|
||||||
and obj.user != request.user
|
|
||||||
and not request.user.is_superuser
|
|
||||||
):
|
|
||||||
self.permission_denied(request, message="You cannot edit/delete this rating.")
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"], permission_classes=[permissions.AllowAny])
|
|
||||||
def recent(self, request: Request) -> Response:
|
|
||||||
recent_ratings = (
|
|
||||||
Rating.objects.filter(comment__isnull=False)
|
|
||||||
.exclude(comment="")
|
|
||||||
.select_related("user", "flavor")
|
|
||||||
.order_by("-created_at")[:10]
|
|
||||||
)
|
|
||||||
return Response(self.get_serializer(recent_ratings, many=True).data)
|
|
||||||
|
|
||||||
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated])
|
|
||||||
def reply(self, request: Request, pk=None) -> Response:
|
|
||||||
me = current_user(request)
|
|
||||||
rating = self.get_object()
|
|
||||||
text = request.data.get("text")
|
|
||||||
if not text:
|
|
||||||
return Response({"error": "Text is required"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
reply = Reply.objects.create(user=me, rating=rating, text=text)
|
|
||||||
|
|
||||||
if rating.user != me:
|
|
||||||
Notification.objects.create(
|
|
||||||
recipient=rating.user,
|
|
||||||
actor=me,
|
|
||||||
notification_type="reply",
|
|
||||||
rating=rating,
|
|
||||||
reply=reply,
|
|
||||||
)
|
|
||||||
|
|
||||||
parse_mentions(text, me, rating=rating, reply=reply)
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
ReplySerializer(reply, context={"request": request}).data,
|
|
||||||
status=status.HTTP_201_CREATED,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ReplyViewSet(viewsets.ModelViewSet):
|
|
||||||
queryset = Reply.objects.all()
|
|
||||||
serializer_class = ReplySerializer
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return Reply.objects.select_related("user", "rating").order_by("created_at")
|
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
|
||||||
me = current_user(self.request)
|
|
||||||
reply = self.get_object()
|
|
||||||
if reply.user != me and not me.is_superuser:
|
|
||||||
raise PermissionDenied("You cannot edit this reply.")
|
|
||||||
new_reply = serializer.save()
|
|
||||||
parse_mentions(new_reply.text, me, rating=new_reply.rating, reply=new_reply)
|
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
|
||||||
me = current_user(self.request)
|
|
||||||
if instance.user != me and not me.is_superuser:
|
|
||||||
raise PermissionDenied("You cannot delete this reply.")
|
|
||||||
instance.delete()
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
from rest_framework import permissions, status, viewsets
|
|
||||||
from rest_framework.decorators import action
|
|
||||||
from rest_framework.exceptions import PermissionDenied
|
|
||||||
from rest_framework.request import Request
|
|
||||||
from rest_framework.response import Response
|
|
||||||
|
|
||||||
from api.models import Notification, Ticket, TicketMessage, User
|
|
||||||
from api.serializers import TicketMessageSerializer, TicketSerializer
|
|
||||||
from api.utils.auth import current_user
|
|
||||||
|
|
||||||
|
|
||||||
class TicketViewSet(viewsets.ModelViewSet):
|
|
||||||
serializer_class = TicketSerializer
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
if getattr(self, "swagger_fake_view", False):
|
|
||||||
return Ticket.objects.none()
|
|
||||||
me = current_user(self.request)
|
|
||||||
if me.is_superuser:
|
|
||||||
return (
|
|
||||||
Ticket.objects.all()
|
|
||||||
.prefetch_related("messages", "messages__user")
|
|
||||||
.order_by("-updated_at")
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
Ticket.objects.filter(user=me)
|
|
||||||
.prefetch_related("messages", "messages__user")
|
|
||||||
.order_by("-updated_at")
|
|
||||||
)
|
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
|
||||||
me = current_user(self.request)
|
|
||||||
ticket = serializer.save(user=me)
|
|
||||||
admins = User.objects.filter(is_superuser=True).exclude(pk=me.pk)
|
|
||||||
for admin in admins:
|
|
||||||
Notification.objects.create(
|
|
||||||
recipient=admin,
|
|
||||||
actor=me,
|
|
||||||
notification_type="ticket_new",
|
|
||||||
ticket=ticket,
|
|
||||||
)
|
|
||||||
|
|
||||||
@action(detail=True, methods=["post"])
|
|
||||||
def add_message(self, request: Request, pk=None) -> Response:
|
|
||||||
me = current_user(request)
|
|
||||||
ticket = self.get_object()
|
|
||||||
text = request.data.get("text")
|
|
||||||
if not text:
|
|
||||||
return Response({"error": "Text is required"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
msg = TicketMessage.objects.create(ticket=ticket, user=me, text=text)
|
|
||||||
|
|
||||||
if me.is_superuser:
|
|
||||||
if ticket.user != me:
|
|
||||||
Notification.objects.create(
|
|
||||||
recipient=ticket.user,
|
|
||||||
actor=me,
|
|
||||||
notification_type="ticket_reply",
|
|
||||||
ticket=ticket,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
admins = User.objects.filter(is_superuser=True).exclude(pk=me.pk)
|
|
||||||
for admin in admins:
|
|
||||||
Notification.objects.create(
|
|
||||||
recipient=admin,
|
|
||||||
actor=me,
|
|
||||||
notification_type="ticket_reply",
|
|
||||||
ticket=ticket,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response(TicketMessageSerializer(msg).data, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
|
|
||||||
def update_status(self, request: Request, pk=None) -> Response:
|
|
||||||
ticket = self.get_object()
|
|
||||||
new_status = request.data.get("status")
|
|
||||||
if new_status in dict(Ticket.STATUS_CHOICES):
|
|
||||||
ticket.status = new_status
|
|
||||||
ticket.save()
|
|
||||||
return Response({"status": "updated"})
|
|
||||||
return Response({"error": "Invalid status"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
|
||||||
if not request.user.is_superuser:
|
|
||||||
raise PermissionDenied("Only admins can delete tickets.")
|
|
||||||
return super().destroy(request, *args, **kwargs)
|
|
||||||
@@ -1,562 +0,0 @@
|
|||||||
import secrets
|
|
||||||
import string
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.utils.decorators import method_decorator
|
|
||||||
from django_ratelimit.decorators import ratelimit
|
|
||||||
from rest_framework import permissions, status, viewsets
|
|
||||||
from rest_framework.decorators import action
|
|
||||||
from rest_framework.request import Request
|
|
||||||
from rest_framework.response import Response
|
|
||||||
|
|
||||||
from api.models import Banner, Flavor, Notification, ProfileComment, User
|
|
||||||
from api.serializers import (
|
|
||||||
FlavorSerializer,
|
|
||||||
NotificationSerializer,
|
|
||||||
ProfileCommentSerializer,
|
|
||||||
RatingSerializer,
|
|
||||||
UserSerializer,
|
|
||||||
)
|
|
||||||
from api.services.ip_logging import log_user_ip
|
|
||||||
from api.tasks import send_email_task
|
|
||||||
from api.utils.auth import current_user
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_code() -> str:
|
|
||||||
return "".join(secrets.choice(string.digits) for _ in range(6))
|
|
||||||
|
|
||||||
|
|
||||||
class UserViewSet(viewsets.ModelViewSet):
|
|
||||||
queryset = User.objects.all()
|
|
||||||
serializer_class = UserSerializer
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
def get_permissions(self):
|
|
||||||
if self.action in [
|
|
||||||
"signup",
|
|
||||||
"verify_signup",
|
|
||||||
"request_password_reset",
|
|
||||||
"complete_password_reset",
|
|
||||||
]:
|
|
||||||
return [permissions.AllowAny()]
|
|
||||||
return super().get_permissions()
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"], permission_classes=[permissions.IsAuthenticated])
|
|
||||||
def following_list(self, request: Request) -> Response:
|
|
||||||
me = current_user(request)
|
|
||||||
following = me.following.all()
|
|
||||||
return Response(self.get_serializer(following, many=True).data)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny])
|
|
||||||
@method_decorator(ratelimit(key="ip", rate="5/h", method="POST", block=True))
|
|
||||||
def signup(self, request: Request) -> Response:
|
|
||||||
username = request.data.get("username")
|
|
||||||
email = request.data.get("email")
|
|
||||||
password = request.data.get("password")
|
|
||||||
if not username or not email or not password:
|
|
||||||
return Response(
|
|
||||||
{"error": "Username, email and password required"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
if User.objects.filter(username=username).exists():
|
|
||||||
return Response(
|
|
||||||
{"error": "Username already exists"}, status=status.HTTP_400_BAD_REQUEST
|
|
||||||
)
|
|
||||||
if User.objects.filter(email=email).exists():
|
|
||||||
return Response({"error": "Email already exists"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
code = _generate_code()
|
|
||||||
user = User.objects.create_user(
|
|
||||||
username=username,
|
|
||||||
email=email,
|
|
||||||
password=password,
|
|
||||||
is_active=False,
|
|
||||||
email_confirmation_code=code,
|
|
||||||
)
|
|
||||||
|
|
||||||
verification_link = f"{settings.FRONTEND_URL}/verify-email?username={username}&code={code}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
send_email_task.delay(
|
|
||||||
subject="Verify your Holy Flavors account",
|
|
||||||
message=(
|
|
||||||
f"Hi {username},\n\nYour verification code is: {code}\n\n"
|
|
||||||
f"Alternatively, you can complete your registration by clicking the link below:\n"
|
|
||||||
f"{verification_link}\n\nWelcome to the archive!"
|
|
||||||
),
|
|
||||||
recipient_list=[email],
|
|
||||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
user.delete()
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
"error": (
|
|
||||||
f"Failed to send verification email. Please check your SMTP settings "
|
|
||||||
f"in the .env file. The server error was: {e}"
|
|
||||||
)
|
|
||||||
},
|
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
"status": "User created, please verify your email",
|
|
||||||
"username": username,
|
|
||||||
"email": email,
|
|
||||||
},
|
|
||||||
status=status.HTTP_201_CREATED,
|
|
||||||
)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny])
|
|
||||||
def resend_verification(self, request: Request) -> Response:
|
|
||||||
username = request.data.get("username")
|
|
||||||
if not username:
|
|
||||||
return Response({"error": "Username required"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
try:
|
|
||||||
user = User.objects.get(username=username, is_active=False)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
return Response(
|
|
||||||
{"error": "User not found or already active"},
|
|
||||||
status=status.HTTP_404_NOT_FOUND,
|
|
||||||
)
|
|
||||||
|
|
||||||
code = _generate_code()
|
|
||||||
user.email_confirmation_code = code
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
verification_link = (
|
|
||||||
f"{settings.FRONTEND_URL}/verify-email?username={user.username}&code={code}"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
send_email_task.delay(
|
|
||||||
subject="Verify your Holy Flavors account",
|
|
||||||
message=(
|
|
||||||
f"Hi {user.username},\n\nYour new verification code is: {code}\n\n"
|
|
||||||
f"Alternatively, you can complete your registration by clicking the link below:\n"
|
|
||||||
f"{verification_link}"
|
|
||||||
),
|
|
||||||
recipient_list=[user.email],
|
|
||||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return Response(
|
|
||||||
{"error": f"Failed to send email: {e}"},
|
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response({"status": "Verification code resent!"})
|
|
||||||
|
|
||||||
@action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny])
|
|
||||||
def verify_signup(self, request: Request) -> Response:
|
|
||||||
username = request.data.get("username")
|
|
||||||
code = request.data.get("code")
|
|
||||||
try:
|
|
||||||
user = User.objects.get(username=username, is_active=False)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
return Response(
|
|
||||||
{"error": "User not found or already active"},
|
|
||||||
status=status.HTTP_404_NOT_FOUND,
|
|
||||||
)
|
|
||||||
|
|
||||||
if user.email_confirmation_code == code:
|
|
||||||
user.is_active = True
|
|
||||||
user.email_confirmation_code = None
|
|
||||||
user.save()
|
|
||||||
return Response({"status": "Account verified successfully!"})
|
|
||||||
return Response({"error": "Invalid verification code"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
@action(
|
|
||||||
detail=False,
|
|
||||||
methods=["get"],
|
|
||||||
permission_classes=[permissions.AllowAny],
|
|
||||||
url_path="profile/(?P<username>[^/.]+)",
|
|
||||||
)
|
|
||||||
def public_profile(self, request: Request, username=None) -> Response:
|
|
||||||
try:
|
|
||||||
user = User.objects.get(username=username)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
return Response({"error": "User not found"}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
ratings = user.ratings.select_related("flavor", "flavor__category").order_by("-score")
|
|
||||||
comments = user.profile_comments.select_related("author").order_by("-created_at")
|
|
||||||
|
|
||||||
followers = user.followers.all()
|
|
||||||
following = user.following.all()
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
"id": user.id,
|
|
||||||
"username": user.username,
|
|
||||||
"theme": user.theme,
|
|
||||||
"avatar": (request.build_absolute_uri(user.avatar.url) if user.avatar else None),
|
|
||||||
"following_count": following.count(),
|
|
||||||
"followers_count": followers.count(),
|
|
||||||
"is_following": (
|
|
||||||
request.user.following.filter(pk=user.pk).exists()
|
|
||||||
if request.user.is_authenticated
|
|
||||||
else False
|
|
||||||
),
|
|
||||||
"ratings": RatingSerializer(ratings, many=True, context={"request": request}).data,
|
|
||||||
"comments": ProfileCommentSerializer(
|
|
||||||
comments, many=True, context={"request": request}
|
|
||||||
).data,
|
|
||||||
"followers": UserSerializer(
|
|
||||||
followers, many=True, context={"request": request}
|
|
||||||
).data,
|
|
||||||
"following": UserSerializer(
|
|
||||||
following, many=True, context={"request": request}
|
|
||||||
).data,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated])
|
|
||||||
def add_comment(self, request: Request, pk=None) -> Response:
|
|
||||||
me = current_user(request)
|
|
||||||
profile_owner = self.get_object()
|
|
||||||
text = request.data.get("text")
|
|
||||||
if not text:
|
|
||||||
return Response({"error": "Text is required"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
comment = ProfileComment.objects.create(
|
|
||||||
profile_owner=profile_owner,
|
|
||||||
author=me,
|
|
||||||
text=text,
|
|
||||||
)
|
|
||||||
|
|
||||||
if profile_owner != me:
|
|
||||||
Notification.objects.create(
|
|
||||||
recipient=profile_owner,
|
|
||||||
actor=me,
|
|
||||||
notification_type="profile_comment",
|
|
||||||
profile_comment=comment,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
ProfileCommentSerializer(comment, context={"request": request}).data,
|
|
||||||
status=status.HTTP_201_CREATED,
|
|
||||||
)
|
|
||||||
|
|
||||||
@action(
|
|
||||||
detail=True,
|
|
||||||
methods=["delete"],
|
|
||||||
url_path="delete_comment/(?P<comment_id>[^/.]+)",
|
|
||||||
permission_classes=[permissions.IsAuthenticated],
|
|
||||||
)
|
|
||||||
def delete_comment(self, request: Request, pk=None, comment_id=None) -> Response:
|
|
||||||
me = current_user(request)
|
|
||||||
profile_owner = self.get_object()
|
|
||||||
try:
|
|
||||||
comment = ProfileComment.objects.get(pk=comment_id, profile_owner=profile_owner)
|
|
||||||
except ProfileComment.DoesNotExist:
|
|
||||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
if comment.author != me and not me.is_superuser and profile_owner != me:
|
|
||||||
return Response(status=status.HTTP_403_FORBIDDEN)
|
|
||||||
|
|
||||||
comment.delete()
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"])
|
|
||||||
def me(self, request: Request) -> Response:
|
|
||||||
me = current_user(request)
|
|
||||||
log_user_ip(me, request)
|
|
||||||
return Response(self.get_serializer(me).data)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["patch"])
|
|
||||||
def update_preferences(self, request: Request) -> Response:
|
|
||||||
user = current_user(request)
|
|
||||||
theme = request.data.get("theme")
|
|
||||||
language = request.data.get("language")
|
|
||||||
drawer_anchor = request.data.get("drawer_anchor")
|
|
||||||
banner_id = request.data.get("selected_banner")
|
|
||||||
|
|
||||||
updated = False
|
|
||||||
if theme in dict(User.THEME_CHOICES):
|
|
||||||
user.theme = theme
|
|
||||||
updated = True
|
|
||||||
if language in dict(User.LANGUAGE_CHOICES):
|
|
||||||
user.language = language
|
|
||||||
updated = True
|
|
||||||
if drawer_anchor in dict(User.DRAWER_ANCHOR_CHOICES):
|
|
||||||
user.drawer_anchor = drawer_anchor
|
|
||||||
updated = True
|
|
||||||
|
|
||||||
if banner_id is not None:
|
|
||||||
if banner_id == "":
|
|
||||||
user.selected_banner = None
|
|
||||||
updated = True
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
banner = Banner.objects.get(pk=banner_id, is_enabled=True)
|
|
||||||
user.selected_banner = banner
|
|
||||||
updated = True
|
|
||||||
except Banner.DoesNotExist:
|
|
||||||
return Response(
|
|
||||||
{"error": "Invalid or disabled banner"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
if updated:
|
|
||||||
user.save()
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
"status": "preferences updated",
|
|
||||||
"theme": user.theme,
|
|
||||||
"language": user.language,
|
|
||||||
"drawer_anchor": user.drawer_anchor,
|
|
||||||
"selected_banner": (user.selected_banner.id if user.selected_banner else None),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return Response({"error": "No valid updates provided"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["post"])
|
|
||||||
def update_avatar(self, request: Request) -> Response:
|
|
||||||
user = current_user(request)
|
|
||||||
avatar = request.FILES.get("avatar")
|
|
||||||
if not avatar:
|
|
||||||
return Response({"error": "No avatar provided"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
if avatar.size > 2 * 1024 * 1024:
|
|
||||||
return Response(
|
|
||||||
{"error": "File size exceeds 2MB limit"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
user.avatar = avatar
|
|
||||||
user.save()
|
|
||||||
return Response(self.get_serializer(user).data)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["post"])
|
|
||||||
def change_password(self, request: Request) -> Response:
|
|
||||||
user = current_user(request)
|
|
||||||
old_password = request.data.get("old_password")
|
|
||||||
new_password = request.data.get("new_password")
|
|
||||||
if not user.check_password(old_password):
|
|
||||||
return Response({"error": "Wrong old password"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
user.set_password(new_password)
|
|
||||||
user.save()
|
|
||||||
return Response({"status": "password changed"})
|
|
||||||
|
|
||||||
@action(detail=False, methods=["patch"])
|
|
||||||
def update_profile(self, request: Request) -> Response:
|
|
||||||
user = current_user(request)
|
|
||||||
username = request.data.get("username")
|
|
||||||
email = request.data.get("email")
|
|
||||||
|
|
||||||
message = ""
|
|
||||||
if username and username != user.username:
|
|
||||||
if User.objects.filter(username=username).exists():
|
|
||||||
return Response(
|
|
||||||
{"error": "Username already taken"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
user.username = username
|
|
||||||
|
|
||||||
if email and email != user.email:
|
|
||||||
code = _generate_code()
|
|
||||||
user.pending_email = email
|
|
||||||
user.email_confirmation_code = code
|
|
||||||
send_email_task.delay(
|
|
||||||
subject="Confirm your new email",
|
|
||||||
message=f"Hi {user.username},\n\nYour confirmation code is: {code}",
|
|
||||||
recipient_list=[email],
|
|
||||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
|
||||||
)
|
|
||||||
message = " Confirmation code sent to new email."
|
|
||||||
|
|
||||||
user.save()
|
|
||||||
data = self.get_serializer(user).data
|
|
||||||
if message:
|
|
||||||
data["message"] = message
|
|
||||||
return Response(data)
|
|
||||||
|
|
||||||
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated])
|
|
||||||
def follow(self, request: Request, pk=None) -> Response:
|
|
||||||
me = current_user(request)
|
|
||||||
user_to_follow = self.get_object()
|
|
||||||
if user_to_follow == me:
|
|
||||||
return Response(
|
|
||||||
{"error": "You cannot follow yourself"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not me.following.filter(pk=user_to_follow.pk).exists():
|
|
||||||
me.following.add(user_to_follow)
|
|
||||||
Notification.objects.create(
|
|
||||||
recipient=user_to_follow,
|
|
||||||
actor=me,
|
|
||||||
notification_type="follow",
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response({"status": "followed"})
|
|
||||||
|
|
||||||
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated])
|
|
||||||
def unfollow(self, request: Request, pk=None) -> Response:
|
|
||||||
me = current_user(request)
|
|
||||||
user_to_unfollow = self.get_object()
|
|
||||||
me.following.remove(user_to_unfollow)
|
|
||||||
return Response({"status": "unfollowed"})
|
|
||||||
|
|
||||||
@action(detail=False, methods=["post"])
|
|
||||||
def confirm_email(self, request: Request) -> Response:
|
|
||||||
user = current_user(request)
|
|
||||||
code = request.data.get("code")
|
|
||||||
if not user.email_confirmation_code or code != user.email_confirmation_code:
|
|
||||||
return Response(
|
|
||||||
{"error": "Invalid or expired code"}, status=status.HTTP_400_BAD_REQUEST
|
|
||||||
)
|
|
||||||
if not user.pending_email:
|
|
||||||
return Response(
|
|
||||||
{"error": "No pending email change"}, status=status.HTTP_400_BAD_REQUEST
|
|
||||||
)
|
|
||||||
|
|
||||||
user.email = user.pending_email
|
|
||||||
user.pending_email = None
|
|
||||||
user.email_confirmation_code = None
|
|
||||||
user.save()
|
|
||||||
return Response({"status": "Email confirmed", "email": user.email})
|
|
||||||
|
|
||||||
@action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny])
|
|
||||||
@method_decorator(ratelimit(key="ip", rate="3/h", method="POST", block=True))
|
|
||||||
def request_password_reset(self, request: Request) -> Response:
|
|
||||||
email = request.data.get("email")
|
|
||||||
try:
|
|
||||||
user = User.objects.get(email=email, is_active=True)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
return Response(
|
|
||||||
{"status": "If an account exists with this email, a reset code has been sent."}
|
|
||||||
)
|
|
||||||
|
|
||||||
code = _generate_code()
|
|
||||||
user.email_confirmation_code = code
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
send_email_task.delay(
|
|
||||||
subject="Password Reset Request",
|
|
||||||
message=f"Hi {user.username},\n\nYour password reset code is: {code}",
|
|
||||||
recipient_list=[email],
|
|
||||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
|
||||||
)
|
|
||||||
return Response(
|
|
||||||
{"status": "If an account exists with this email, a reset code has been sent."}
|
|
||||||
)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny])
|
|
||||||
@method_decorator(ratelimit(key="ip", rate="5/h", method="POST", block=True))
|
|
||||||
def complete_password_reset(self, request: Request) -> Response:
|
|
||||||
email = request.data.get("email")
|
|
||||||
code = request.data.get("code")
|
|
||||||
new_password = request.data.get("password")
|
|
||||||
|
|
||||||
try:
|
|
||||||
user = User.objects.get(email=email, email_confirmation_code=code)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
return Response({"error": "Invalid email or code"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
user.set_password(new_password)
|
|
||||||
user.email_confirmation_code = None
|
|
||||||
user.save()
|
|
||||||
return Response({"status": "Password reset successful!"})
|
|
||||||
|
|
||||||
@action(detail=False, methods=["post"])
|
|
||||||
@method_decorator(ratelimit(key="user", rate="3/h", method="POST", block=True))
|
|
||||||
def request_account_deletion(self, request: Request) -> Response:
|
|
||||||
code = _generate_code()
|
|
||||||
user = current_user(request)
|
|
||||||
user.email_confirmation_code = code
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
send_email_task.delay(
|
|
||||||
subject="Confirm Account Deletion - Holy Flavors Archive",
|
|
||||||
message=(
|
|
||||||
f"Hi {user.username},\n\nYou requested to delete your account. "
|
|
||||||
f"This action is permanent.\n\nYour deletion code is: {code}"
|
|
||||||
),
|
|
||||||
recipient_list=[user.email],
|
|
||||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
|
||||||
)
|
|
||||||
return Response({"status": "Code sent to your email"})
|
|
||||||
|
|
||||||
@action(detail=False, methods=["post"])
|
|
||||||
def confirm_account_deletion(self, request: Request) -> Response:
|
|
||||||
user = current_user(request)
|
|
||||||
code = request.data.get("code")
|
|
||||||
if not user.email_confirmation_code or code != user.email_confirmation_code:
|
|
||||||
return Response(
|
|
||||||
{"error": "Invalid or expired code"}, status=status.HTTP_400_BAD_REQUEST
|
|
||||||
)
|
|
||||||
|
|
||||||
user.delete()
|
|
||||||
return Response({"status": "Account deleted"})
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"])
|
|
||||||
def dashboard(self, request: Request) -> Response:
|
|
||||||
from django.db.models import Avg, Q
|
|
||||||
|
|
||||||
user = current_user(request)
|
|
||||||
rated_ids = user.ratings.values_list("flavor_id", flat=True)
|
|
||||||
followed_users = user.following.all()
|
|
||||||
|
|
||||||
missing_flavors = (
|
|
||||||
Flavor.objects.exclude(id__in=rated_ids)
|
|
||||||
.exclude(category__name="Packs and other")
|
|
||||||
.select_related("category")
|
|
||||||
.annotate(
|
|
||||||
average_rating=Avg("ratings__score"),
|
|
||||||
followed_average_rating=Avg(
|
|
||||||
"ratings__score", filter=Q(ratings__user__in=followed_users)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.order_by("category__name", "name")
|
|
||||||
)
|
|
||||||
|
|
||||||
rated_flavors = (
|
|
||||||
user.ratings.select_related("flavor", "flavor__category")
|
|
||||||
.prefetch_related("replies", "replies__user")
|
|
||||||
.order_by("-created_at")
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
"user": UserSerializer(user).data,
|
|
||||||
"rated_count": rated_flavors.count(),
|
|
||||||
"missing_count": missing_flavors.count(),
|
|
||||||
"missing_flavors": FlavorSerializer(
|
|
||||||
missing_flavors, many=True, context={"request": request}
|
|
||||||
).data,
|
|
||||||
"my_ratings": RatingSerializer(
|
|
||||||
rated_flavors, many=True, context={"request": request}
|
|
||||||
).data,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationViewSet(viewsets.ReadOnlyModelViewSet):
|
|
||||||
serializer_class = NotificationSerializer
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
pagination_class = None
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
if getattr(self, "swagger_fake_view", False):
|
|
||||||
return Notification.objects.none()
|
|
||||||
me = current_user(self.request)
|
|
||||||
return me.notifications.select_related(
|
|
||||||
"actor", "rating__flavor", "reply__rating__flavor"
|
|
||||||
).order_by("-created_at")
|
|
||||||
|
|
||||||
@action(detail=False, methods=["post"])
|
|
||||||
def mark_all_read(self, request: Request) -> Response:
|
|
||||||
me = current_user(request)
|
|
||||||
me.notifications.filter(is_read=False).update(is_read=True)
|
|
||||||
return Response({"status": "all read"})
|
|
||||||
|
|
||||||
@action(detail=True, methods=["post"])
|
|
||||||
def mark_read(self, request: Request, pk=None) -> Response:
|
|
||||||
notification = self.get_object()
|
|
||||||
notification.is_read = True
|
|
||||||
notification.save()
|
|
||||||
return Response({"status": "marked read"})
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Worker/beat share the backend image but must not re-run migrate/seed on every
|
|
||||||
# restart. Set SKIP_INIT=true on those services. Only the primary backend
|
|
||||||
# container runs the init block below.
|
|
||||||
if [ "${SKIP_INIT:-false}" = "true" ]; then
|
|
||||||
echo "SKIP_INIT=true — skipping migrations/seed."
|
|
||||||
if [ "$#" -gt 0 ]; then
|
|
||||||
echo "Executing: $@"
|
|
||||||
exec "$@"
|
|
||||||
fi
|
|
||||||
echo "No command given with SKIP_INIT=true; exiting."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Applying database migrations..."
|
|
||||||
python manage.py migrate --noinput
|
|
||||||
|
|
||||||
echo "Collecting static files..."
|
|
||||||
python manage.py collectstatic --no-input
|
|
||||||
|
|
||||||
mkdir -p media/flavors
|
|
||||||
|
|
||||||
# One-shot seed jobs. These should run on image first boot, not on every
|
|
||||||
# restart. A marker file on the bind-mounted volume gates them.
|
|
||||||
SEED_MARKER="/app/.seeded"
|
|
||||||
if [ ! -f "$SEED_MARKER" ]; then
|
|
||||||
echo "First boot — running initial seed commands..."
|
|
||||||
python manage.py sync_flavors || echo "sync_flavors failed (non-fatal on first boot)"
|
|
||||||
python manage.py cleanup_duplicates || echo "cleanup_duplicates failed (non-fatal)"
|
|
||||||
python manage.py seed_legacy_flavors || echo "seed_legacy_flavors failed (non-fatal)"
|
|
||||||
python manage.py seed_banners || echo "seed_banners failed (non-fatal)"
|
|
||||||
touch "$SEED_MARKER"
|
|
||||||
else
|
|
||||||
echo "Seed marker present — skipping seed commands. Trigger them from /admin if needed."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$#" -gt 0 ]; then
|
|
||||||
echo "Executing custom command: $@"
|
|
||||||
exec "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Starting Gunicorn..."
|
|
||||||
exec gunicorn holy_backend.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 120
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from holy_backend.celery import app as celery_app
|
|
||||||
|
|
||||||
__all__ = ("celery_app",)
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
"""
|
|
||||||
ASGI config for holy_backend project.
|
|
||||||
|
|
||||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
|
||||||
|
|
||||||
For more information on this file, see
|
|
||||||
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
|
|
||||||
|
|
||||||
application = get_asgi_application()
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
"""Celery application for holy_backend.
|
|
||||||
|
|
||||||
Autodiscovers tasks from installed Django apps. Broker/backend configuration
|
|
||||||
is read from Django settings (CELERY_* namespace).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
from celery import Celery
|
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
|
|
||||||
|
|
||||||
app = Celery("holy_backend")
|
|
||||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
|
||||||
app.autodiscover_tasks()
|
|
||||||
Whitespace-only changes.
@@ -1,206 +0,0 @@
|
|||||||
"""
|
|
||||||
Base Django settings shared across all environments.
|
|
||||||
|
|
||||||
Environment-specific overrides live in `dev.py` and `prod.py`. Select one with
|
|
||||||
the DJANGO_SETTINGS_MODULE environment variable.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
from datetime import timedelta
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
|
||||||
|
|
||||||
|
|
||||||
def get_env_list(var_name: str, default_list: list[str]) -> list[str]:
|
|
||||||
val = os.environ.get(var_name)
|
|
||||||
if val:
|
|
||||||
return [item.strip() for item in val.split(",")]
|
|
||||||
return default_list
|
|
||||||
|
|
||||||
|
|
||||||
DOMAIN = os.environ.get("DOMAIN", "localhost")
|
|
||||||
|
|
||||||
ALLOWED_HOSTS = get_env_list(
|
|
||||||
"ALLOWED_HOSTS",
|
|
||||||
["localhost", "127.0.0.1", "backend", "worker", DOMAIN],
|
|
||||||
)
|
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
"whitenoise.runserver_nostatic",
|
|
||||||
"django.contrib.admin",
|
|
||||||
"django.contrib.auth",
|
|
||||||
"django.contrib.contenttypes",
|
|
||||||
"django.contrib.sessions",
|
|
||||||
"django.contrib.messages",
|
|
||||||
"django.contrib.staticfiles",
|
|
||||||
# Third party
|
|
||||||
"rest_framework",
|
|
||||||
"rest_framework.authtoken",
|
|
||||||
"rest_framework_simplejwt",
|
|
||||||
"rest_framework_simplejwt.token_blacklist",
|
|
||||||
"corsheaders",
|
|
||||||
"django_filters",
|
|
||||||
"drf_spectacular",
|
|
||||||
"django_ratelimit",
|
|
||||||
"django_celery_beat",
|
|
||||||
# Local
|
|
||||||
"api",
|
|
||||||
]
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
|
||||||
"django.middleware.security.SecurityMiddleware",
|
|
||||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
|
||||||
"compression_middleware.middleware.CompressionMiddleware",
|
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
||||||
"corsheaders.middleware.CorsMiddleware",
|
|
||||||
"django.middleware.common.CommonMiddleware",
|
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
|
||||||
]
|
|
||||||
|
|
||||||
ROOT_URLCONF = "holy_backend.urls"
|
|
||||||
|
|
||||||
TEMPLATES = [
|
|
||||||
{
|
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
||||||
"DIRS": [],
|
|
||||||
"APP_DIRS": True,
|
|
||||||
"OPTIONS": {
|
|
||||||
"context_processors": [
|
|
||||||
"django.template.context_processors.request",
|
|
||||||
"django.contrib.auth.context_processors.auth",
|
|
||||||
"django.contrib.messages.context_processors.messages",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
WSGI_APPLICATION = "holy_backend.wsgi.application"
|
|
||||||
|
|
||||||
DATABASES = {
|
|
||||||
"default": {
|
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
|
||||||
"NAME": BASE_DIR / "db.sqlite3",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DJANGO_CACHE_URL = os.environ.get("DJANGO_CACHE_URL", "redis://redis:6379/1")
|
|
||||||
|
|
||||||
CACHES = {
|
|
||||||
"default": {
|
|
||||||
"BACKEND": "django.core.cache.backends.redis.RedisCache",
|
|
||||||
"LOCATION": DJANGO_CACHE_URL,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SILENCED_SYSTEM_CHECKS = ["django_ratelimit.W001", "django_ratelimit.E003"]
|
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
|
||||||
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
|
||||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
|
||||||
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
|
||||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
|
||||||
]
|
|
||||||
|
|
||||||
LANGUAGE_CODE = "en-us"
|
|
||||||
TIME_ZONE = "UTC"
|
|
||||||
USE_I18N = True
|
|
||||||
USE_TZ = True
|
|
||||||
|
|
||||||
STATIC_URL = "static/"
|
|
||||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
|
||||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedStaticFilesStorage"
|
|
||||||
WHITENOISE_INDEX_PAGE = True
|
|
||||||
|
|
||||||
MEDIA_URL = "media/"
|
|
||||||
MEDIA_ROOT = BASE_DIR / "media"
|
|
||||||
|
|
||||||
# Reverse proxy
|
|
||||||
USE_X_FORWARDED_HOST = True
|
|
||||||
USE_X_FORWARDED_PORT = True
|
|
||||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = get_env_list(
|
|
||||||
"CSRF_TRUSTED_ORIGINS",
|
|
||||||
[f"https://{DOMAIN}", "http://localhost:5173"],
|
|
||||||
)
|
|
||||||
|
|
||||||
AUTH_USER_MODEL = "api.User"
|
|
||||||
|
|
||||||
REST_FRAMEWORK = {
|
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
|
||||||
"api.utils.cookie_auth.CookieJWTAuthentication",
|
|
||||||
],
|
|
||||||
"DEFAULT_PERMISSION_CLASSES": [
|
|
||||||
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
|
|
||||||
],
|
|
||||||
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
|
|
||||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
|
||||||
"PAGE_SIZE": 50,
|
|
||||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
|
||||||
}
|
|
||||||
|
|
||||||
SIMPLE_JWT = {
|
|
||||||
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=60),
|
|
||||||
"REFRESH_TOKEN_LIFETIME": timedelta(days=14),
|
|
||||||
"ROTATE_REFRESH_TOKENS": True,
|
|
||||||
"BLACKLIST_AFTER_ROTATION": True,
|
|
||||||
"AUTH_HEADER_TYPES": ("Bearer",),
|
|
||||||
}
|
|
||||||
|
|
||||||
# httpOnly-cookie JWT config. The Authorization header path is kept as a
|
|
||||||
# fallback during the rollout — once all clients use cookies it can be removed.
|
|
||||||
JWT_AUTH_COOKIE_ACCESS = "access_token"
|
|
||||||
JWT_AUTH_COOKIE_REFRESH = "refresh_token"
|
|
||||||
JWT_AUTH_COOKIE_SECURE = False # prod.py overrides to True
|
|
||||||
JWT_AUTH_COOKIE_SAMESITE = "Lax"
|
|
||||||
JWT_AUTH_COOKIE_PATH = "/"
|
|
||||||
|
|
||||||
CORS_ALLOWED_ORIGINS = get_env_list(
|
|
||||||
"CORS_ALLOWED_ORIGINS",
|
|
||||||
[f"https://{DOMAIN}", "http://localhost:5173", "http://127.0.0.1:5173"],
|
|
||||||
)
|
|
||||||
CORS_ALLOW_CREDENTIALS = True
|
|
||||||
|
|
||||||
FRONTEND_URL = os.environ.get("FRONTEND_URL", f"https://{DOMAIN}").rstrip("/")
|
|
||||||
|
|
||||||
# Email (SMTP)
|
|
||||||
EMAIL_HOST = os.environ.get("EMAIL_HOST")
|
|
||||||
EMAIL_PORT = int(os.environ.get("EMAIL_PORT", 587))
|
|
||||||
EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "false").lower() == "true"
|
|
||||||
EMAIL_USE_SSL = os.environ.get("EMAIL_USE_SSL", "false").lower() == "true"
|
|
||||||
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER")
|
|
||||||
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD")
|
|
||||||
EMAIL_SKIP_CERT_VERIFICATION = (
|
|
||||||
os.environ.get("EMAIL_SKIP_CERT_VERIFICATION", "false").lower() == "true"
|
|
||||||
)
|
|
||||||
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", EMAIL_HOST_USER)
|
|
||||||
SERVER_EMAIL = DEFAULT_FROM_EMAIL
|
|
||||||
|
|
||||||
if EMAIL_HOST:
|
|
||||||
EMAIL_BACKEND = "api.email_backend.InsecureEmailBackend"
|
|
||||||
else:
|
|
||||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
|
||||||
|
|
||||||
SPECTACULAR_SETTINGS = {
|
|
||||||
"TITLE": "Holy Flavors API",
|
|
||||||
"DESCRIPTION": "API for rating flavors",
|
|
||||||
"VERSION": "1.0.0",
|
|
||||||
"SERVE_INCLUDE_SCHEMA": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Celery
|
|
||||||
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://redis:6379/0")
|
|
||||||
CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "redis://redis:6379/0")
|
|
||||||
CELERY_TIMEZONE = TIME_ZONE
|
|
||||||
CELERY_TASK_TRACK_STARTED = True
|
|
||||||
CELERY_TASK_TIME_LIMIT = 30 * 60
|
|
||||||
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
|
|
||||||
CELERY_TASK_ALWAYS_EAGER = os.environ.get("CELERY_TASK_ALWAYS_EAGER", "false").lower() == "true"
|
|
||||||
CELERY_TASK_ACKS_LATE = True
|
|
||||||
CELERY_TASK_REJECT_ON_WORKER_LOST = True
|
|
||||||
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
|
|
||||||
CELERY_RESULT_EXPIRES = 3600
|
|
||||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user