mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-30 00:07:03 -05:00
Compare commits
22 Commits
2ff0bf5243
...
nuxt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c95f99ca10 | ||
|
|
aa56c46c27 | ||
|
|
137b9b8cb9 | ||
|
|
00699d53b4 | ||
|
|
cd8868a591 | ||
|
|
ed04b30469 | ||
|
|
a9f5644c5c | ||
|
|
a0be417f74 | ||
|
|
ca770d76ff | ||
|
|
edcd8f2076 | ||
|
|
ae31e889d7 | ||
|
|
2e35f8c5d9 | ||
|
|
45d97b6e68 | ||
|
|
b508434574 | ||
|
|
8f6acbdc23 | ||
|
|
b860e332cb | ||
|
|
7ba0004c93 | ||
|
|
b9063ff4f8 | ||
|
|
bf04e4d854 | ||
|
|
1b246eeaa4 | ||
|
|
fdbbca2add | ||
|
|
bf365693f8 |
@@ -4,7 +4,12 @@
|
|||||||
"Bash(python manage.py check:*)",
|
"Bash(python manage.py check:*)",
|
||||||
"Bash(uv run:*)",
|
"Bash(uv run:*)",
|
||||||
"Bash(find:*)",
|
"Bash(find:*)",
|
||||||
"Bash(python:*)"
|
"Bash(python:*)",
|
||||||
|
"Bash(DJANGO_SETTINGS_MODULE=config.django.local python:*)",
|
||||||
|
"Bash(DJANGO_SETTINGS_MODULE=config.django.local uv run python:*)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(mkdir:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
384
.env.example
384
.env.example
@@ -1,90 +1,372 @@
|
|||||||
# [AWS-SECRET-REMOVED]===========================
|
# ==============================================================================
|
||||||
# ThrillWiki Environment Configuration
|
# ThrillWiki Environment Configuration
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# ==============================================================================
|
||||||
# Copy this file to ***REMOVED*** and fill in your actual values
|
# Copy this file to .env and fill in your actual values
|
||||||
|
# WARNING: Never commit .env files containing real secrets to version control
|
||||||
|
#
|
||||||
|
# This is the primary .env.example for the entire project.
|
||||||
|
# See docs/configuration/environment-variables.md for complete documentation.
|
||||||
|
# See docs/PRODUCTION_CHECKLIST.md for production deployment verification.
|
||||||
|
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# ==============================================================================
|
||||||
# Core Django Settings
|
# PRODUCTION-REQUIRED SETTINGS
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# ==============================================================================
|
||||||
|
# These settings MUST be explicitly configured for production deployments.
|
||||||
|
# The application will NOT function correctly without proper values.
|
||||||
|
#
|
||||||
|
# For complete documentation, see:
|
||||||
|
# - docs/configuration/environment-variables.md (detailed reference)
|
||||||
|
# - docs/PRODUCTION_CHECKLIST.md (deployment verification)
|
||||||
|
#
|
||||||
|
# PRODUCTION REQUIREMENTS:
|
||||||
|
# - DEBUG=False (security)
|
||||||
|
# - DJANGO_SETTINGS_MODULE=config.django.production (correct settings)
|
||||||
|
# - ALLOWED_HOSTS=yourdomain.com (host validation)
|
||||||
|
# - CSRF_TRUSTED_ORIGINS=https://yourdomain.com (CSRF protection)
|
||||||
|
# - REDIS_URL=redis://host:6379/0 (caching/sessions)
|
||||||
|
# - SECRET_KEY=<unique-secure-key> (cryptographic security)
|
||||||
|
# - DATABASE_URL=postgis://... (database connection)
|
||||||
|
#
|
||||||
|
# Validate your production config with:
|
||||||
|
# DJANGO_SETTINGS_MODULE=config.django.production python manage.py check --deploy
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Core Django Settings
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# REQUIRED: Django secret key - generate a new one for each environment
|
||||||
|
# Generate with: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
|
||||||
SECRET_KEY=your-secret-key-here-generate-a-new-one
|
SECRET_KEY=your-secret-key-here-generate-a-new-one
|
||||||
|
|
||||||
|
# Debug mode - MUST be False in production
|
||||||
|
# WARNING: DEBUG=True exposes sensitive information and should NEVER be used in production
|
||||||
DEBUG=True
|
DEBUG=True
|
||||||
|
|
||||||
|
# Django settings module to use
|
||||||
|
# Options: config.django.local, config.django.production, config.django.test
|
||||||
|
# PRODUCTION: Must use config.django.production
|
||||||
|
DJANGO_SETTINGS_MODULE=config.django.local
|
||||||
|
|
||||||
|
# Allowed hosts (comma-separated list)
|
||||||
|
# PRODUCTION: Must include all valid hostnames (no default in production settings)
|
||||||
|
# Example: thrillwiki.com,www.thrillwiki.com,api.thrillwiki.com
|
||||||
ALLOWED_HOSTS=localhost,127.0.0.1,beta.thrillwiki.com
|
ALLOWED_HOSTS=localhost,127.0.0.1,beta.thrillwiki.com
|
||||||
|
|
||||||
|
# CSRF trusted origins (comma-separated, MUST include https:// prefix)
|
||||||
|
# PRODUCTION: Required for all forms and AJAX requests to work
|
||||||
|
# Example: https://thrillwiki.com,https://www.thrillwiki.com
|
||||||
CSRF_TRUSTED_ORIGINS=https://beta.thrillwiki.com,http://localhost:8000
|
CSRF_TRUSTED_ORIGINS=https://beta.thrillwiki.com,http://localhost:8000
|
||||||
|
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# ==============================================================================
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# ==============================================================================
|
||||||
# PostgreSQL with PostGIS for production/development
|
|
||||||
|
# Database URL (supports PostgreSQL, PostGIS, SQLite, SpatiaLite)
|
||||||
|
# PostGIS format: postgis://username:password@host:port/database
|
||||||
|
# PostgreSQL format: postgres://username:password@host:port/database
|
||||||
|
# SQLite format: sqlite:///path/to/db.sqlite3
|
||||||
DATABASE_URL=postgis://username:password@localhost:5432/thrillwiki
|
DATABASE_URL=postgis://username:password@localhost:5432/thrillwiki
|
||||||
|
|
||||||
# SQLite for quick local development (uncomment to use)
|
# Database connection pooling (seconds to keep connections alive)
|
||||||
# DATABASE_URL=spatialite:///path/to/your/db.sqlite3
|
# Set to 0 to disable connection reuse
|
||||||
|
DATABASE_CONN_MAX_AGE=600
|
||||||
|
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# Database connection timeout in seconds
|
||||||
# Cache Configuration
|
DATABASE_CONNECT_TIMEOUT=10
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
|
||||||
# Local memory cache for development
|
|
||||||
CACHE_URL=locmem://
|
|
||||||
|
|
||||||
# Redis for production (uncomment and configure for production)
|
# Query timeout in milliseconds (prevents long-running queries)
|
||||||
# CACHE_URL=redis://localhost:6379/1
|
DATABASE_STATEMENT_TIMEOUT=30000
|
||||||
# REDIS_URL=redis://localhost:6379/0
|
|
||||||
|
|
||||||
|
# Optional: Read replica URL for read-heavy workloads
|
||||||
|
# DATABASE_READ_REPLICA_URL=postgis://username:password@replica-host:5432/thrillwiki
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Cache Configuration
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Redis URL for caching, sessions, and Celery broker
|
||||||
|
# Format: redis://[:password@]host:port/db_number
|
||||||
|
# PRODUCTION: Required - the application uses Redis for:
|
||||||
|
# - Page and API response caching
|
||||||
|
# - Session storage (faster than database sessions)
|
||||||
|
# - Celery task queue broker
|
||||||
|
# Without REDIS_URL in production, caching will fail and performance will degrade.
|
||||||
|
REDIS_URL=redis://localhost:6379/1
|
||||||
|
|
||||||
|
# Optional: Separate Redis URLs for different cache purposes
|
||||||
|
# REDIS_SESSIONS_URL=redis://localhost:6379/2
|
||||||
|
# REDIS_API_URL=redis://localhost:6379/3
|
||||||
|
|
||||||
|
# Redis connection settings
|
||||||
|
REDIS_MAX_CONNECTIONS=100
|
||||||
|
REDIS_CONNECTION_TIMEOUT=20
|
||||||
|
REDIS_IGNORE_EXCEPTIONS=True
|
||||||
|
|
||||||
|
# Cache middleware settings
|
||||||
CACHE_MIDDLEWARE_SECONDS=300
|
CACHE_MIDDLEWARE_SECONDS=300
|
||||||
CACHE_MIDDLEWARE_KEY_PREFIX=thrillwiki
|
CACHE_MIDDLEWARE_KEY_PREFIX=thrillwiki
|
||||||
|
CACHE_KEY_PREFIX=thrillwiki
|
||||||
|
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# Local development cache URL (use for development without Redis)
|
||||||
# Email Configuration
|
# CACHE_URL=locmem://
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Email Configuration
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Email backend
|
||||||
|
# Options:
|
||||||
|
# django.core.mail.backends.console.EmailBackend (development)
|
||||||
|
# django_forwardemail.backends.ForwardEmailBackend (production with ForwardEmail)
|
||||||
|
# django.core.mail.backends.smtp.EmailBackend (custom SMTP)
|
||||||
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
|
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
|
||||||
|
|
||||||
|
# Server email address
|
||||||
SERVER_EMAIL=django_webmaster@thrillwiki.com
|
SERVER_EMAIL=django_webmaster@thrillwiki.com
|
||||||
|
|
||||||
# ForwardEmail configuration (uncomment to use)
|
# Default from email
|
||||||
# EMAIL_BACKEND=email_service.backends.ForwardEmailBackend
|
DEFAULT_FROM_EMAIL=ThrillWiki <noreply@thrillwiki.com>
|
||||||
# FORWARD_EMAIL_BASE_URL=https://api.forwardemail.net
|
|
||||||
|
|
||||||
# SMTP configuration (uncomment to use)
|
# Email subject prefix for admin emails
|
||||||
# EMAIL_URL=smtp://username:password@smtp.example.com:587
|
EMAIL_SUBJECT_PREFIX=[ThrillWiki]
|
||||||
|
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# ForwardEmail configuration (for ForwardEmailBackend)
|
||||||
# Security Settings
|
FORWARD_EMAIL_BASE_URL=https://api.forwardemail.net
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
FORWARD_EMAIL_API_KEY=your-forwardemail-api-key-here
|
||||||
# Cloudflare Turnstile (get keys from Cloudflare dashboard)
|
FORWARD_EMAIL_DOMAIN=your-domain.com
|
||||||
|
|
||||||
|
# SMTP configuration (for SMTPBackend)
|
||||||
|
EMAIL_HOST=smtp.example.com
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_USE_TLS=True
|
||||||
|
EMAIL_USE_SSL=False
|
||||||
|
EMAIL_HOST_USER=your-email@example.com
|
||||||
|
EMAIL_HOST_PASSWORD=your-app-password
|
||||||
|
|
||||||
|
# Email timeout in seconds
|
||||||
|
EMAIL_TIMEOUT=30
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Security Settings
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Cloudflare Turnstile configuration (CAPTCHA alternative)
|
||||||
|
# Get keys from: https://dash.cloudflare.com/?to=/:account/turnstile
|
||||||
TURNSTILE_SITE_KEY=your-turnstile-site-key
|
TURNSTILE_SITE_KEY=your-turnstile-site-key
|
||||||
TURNSTILE_SECRET_KEY=your-turnstile-secret-key
|
TURNSTILE_SECRET_KEY=your-turnstile-secret-key
|
||||||
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify
|
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify
|
||||||
|
|
||||||
# Security headers (set to True for production)
|
# SSL/HTTPS settings (enable all for production)
|
||||||
SECURE_SSL_REDIRECT=False
|
SECURE_SSL_REDIRECT=False
|
||||||
SESSION_COOKIE_SECURE=False
|
SESSION_COOKIE_SECURE=False
|
||||||
CSRF_COOKIE_SECURE=False
|
CSRF_COOKIE_SECURE=False
|
||||||
|
|
||||||
|
# HSTS settings (HTTP Strict Transport Security)
|
||||||
SECURE_HSTS_SECONDS=31536000
|
SECURE_HSTS_SECONDS=31536000
|
||||||
SECURE_HSTS_INCLUDE_SUBDOMAINS=True
|
SECURE_HSTS_INCLUDE_SUBDOMAINS=True
|
||||||
|
SECURE_HSTS_PRELOAD=False
|
||||||
|
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# Security headers
|
||||||
# GeoDjango Settings (macOS with Homebrew)
|
SECURE_BROWSER_XSS_FILTER=True
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
SECURE_CONTENT_TYPE_NOSNIFF=True
|
||||||
|
X_FRAME_OPTIONS=DENY
|
||||||
|
SECURE_REFERRER_POLICY=strict-origin-when-cross-origin
|
||||||
|
SECURE_CROSS_ORIGIN_OPENER_POLICY=same-origin
|
||||||
|
|
||||||
|
# Session settings
|
||||||
|
SESSION_COOKIE_AGE=3600
|
||||||
|
SESSION_SAVE_EVERY_REQUEST=True
|
||||||
|
SESSION_COOKIE_HTTPONLY=True
|
||||||
|
SESSION_COOKIE_SAMESITE=Lax
|
||||||
|
|
||||||
|
# CSRF settings
|
||||||
|
CSRF_COOKIE_HTTPONLY=True
|
||||||
|
CSRF_COOKIE_SAMESITE=Lax
|
||||||
|
|
||||||
|
# Password minimum length
|
||||||
|
PASSWORD_MIN_LENGTH=8
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# GeoDjango Settings
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Library paths for GDAL and GEOS (required for GeoDjango)
|
||||||
|
# macOS with Homebrew:
|
||||||
GDAL_LIBRARY_PATH=/opt/homebrew/lib/libgdal.dylib
|
GDAL_LIBRARY_PATH=/opt/homebrew/lib/libgdal.dylib
|
||||||
GEOS_LIBRARY_PATH=/opt/homebrew/lib/libgeos_c.dylib
|
GEOS_LIBRARY_PATH=/opt/homebrew/lib/libgeos_c.dylib
|
||||||
|
|
||||||
# Linux alternatives (uncomment if on Linux)
|
# Linux alternatives:
|
||||||
# GDAL_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libgdal.so
|
# GDAL_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libgdal.so
|
||||||
# GEOS_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libgeos_c.so
|
# GEOS_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libgeos_c.so
|
||||||
|
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# ==============================================================================
|
||||||
# Optional: Third-party Integrations
|
# API Configuration
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# ==============================================================================
|
||||||
# Sentry for error tracking (uncomment to use)
|
|
||||||
|
# CORS settings
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5174
|
||||||
|
CORS_ALLOW_ALL_ORIGINS=False
|
||||||
|
|
||||||
|
# API rate limiting
|
||||||
|
API_RATE_LIMIT_PER_MINUTE=60
|
||||||
|
API_RATE_LIMIT_PER_HOUR=1000
|
||||||
|
API_RATE_LIMIT_ANON_PER_MINUTE=60
|
||||||
|
API_RATE_LIMIT_USER_PER_HOUR=1000
|
||||||
|
|
||||||
|
# API pagination
|
||||||
|
API_PAGE_SIZE=20
|
||||||
|
API_MAX_PAGE_SIZE=100
|
||||||
|
API_VERSION=1.0.0
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# JWT Configuration
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# JWT token lifetimes
|
||||||
|
JWT_ACCESS_TOKEN_LIFETIME_MINUTES=15
|
||||||
|
JWT_REFRESH_TOKEN_LIFETIME_DAYS=7
|
||||||
|
|
||||||
|
# JWT issuer claim
|
||||||
|
JWT_ISSUER=thrillwiki
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Cloudflare Images Configuration
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Get credentials from Cloudflare dashboard
|
||||||
|
CLOUDFLARE_IMAGES_ACCOUNT_ID=your-cloudflare-account-id
|
||||||
|
CLOUDFLARE_IMAGES_API_TOKEN=your-cloudflare-api-token
|
||||||
|
CLOUDFLARE_IMAGES_ACCOUNT_HASH=your-cloudflare-account-hash
|
||||||
|
CLOUDFLARE_IMAGES_WEBHOOK_SECRET=your-webhook-secret
|
||||||
|
|
||||||
|
# Optional Cloudflare Images settings
|
||||||
|
CLOUDFLARE_IMAGES_DEFAULT_VARIANT=public
|
||||||
|
CLOUDFLARE_IMAGES_UPLOAD_TIMEOUT=300
|
||||||
|
CLOUDFLARE_IMAGES_CLEANUP_HOURS=24
|
||||||
|
CLOUDFLARE_IMAGES_MAX_FILE_SIZE=10485760
|
||||||
|
CLOUDFLARE_IMAGES_REQUIRE_SIGNED_URLS=False
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Road Trip Service Configuration
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# OpenStreetMap user agent (required for OSM API)
|
||||||
|
ROADTRIP_USER_AGENT=ThrillWiki/1.0 (https://thrillwiki.com)
|
||||||
|
|
||||||
|
# Cache timeouts
|
||||||
|
ROADTRIP_CACHE_TIMEOUT=86400
|
||||||
|
ROADTRIP_ROUTE_CACHE_TIMEOUT=21600
|
||||||
|
|
||||||
|
# Request settings
|
||||||
|
ROADTRIP_MAX_REQUESTS_PER_SECOND=1
|
||||||
|
ROADTRIP_REQUEST_TIMEOUT=10
|
||||||
|
ROADTRIP_MAX_RETRIES=3
|
||||||
|
ROADTRIP_BACKOFF_FACTOR=2
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Logging Configuration
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Log directory (relative to backend/)
|
||||||
|
LOG_DIR=logs
|
||||||
|
|
||||||
|
# Log levels (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||||
|
ROOT_LOG_LEVEL=INFO
|
||||||
|
DJANGO_LOG_LEVEL=WARNING
|
||||||
|
DB_LOG_LEVEL=WARNING
|
||||||
|
APP_LOG_LEVEL=INFO
|
||||||
|
PERFORMANCE_LOG_LEVEL=INFO
|
||||||
|
QUERY_LOG_LEVEL=WARNING
|
||||||
|
NPLUSONE_LOG_LEVEL=WARNING
|
||||||
|
REQUEST_LOG_LEVEL=INFO
|
||||||
|
CELERY_LOG_LEVEL=INFO
|
||||||
|
CONSOLE_LOG_LEVEL=INFO
|
||||||
|
FILE_LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Log formatters (verbose, json, simple)
|
||||||
|
FILE_LOG_FORMATTER=json
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Monitoring & Errors
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Sentry configuration (optional, for error tracking)
|
||||||
# SENTRY_DSN=https://your-sentry-dsn-here
|
# SENTRY_DSN=https://your-sentry-dsn-here
|
||||||
|
# SENTRY_ENVIRONMENT=development
|
||||||
|
# SENTRY_TRACES_SAMPLE_RATE=0.1
|
||||||
|
|
||||||
# Google Analytics (uncomment to use)
|
# ==============================================================================
|
||||||
# GOOGLE_ANALYTICS_ID=GA-XXXXXXXXX
|
# Feature Flags
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# Development tools
|
||||||
# Development/Debug Settings
|
ENABLE_DEBUG_TOOLBAR=True
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
ENABLE_SILK_PROFILER=False
|
||||||
# Set to comma-separated list for debug toolbar
|
|
||||||
|
# Django template support (can be disabled for API-only mode)
|
||||||
|
TEMPLATES_ENABLED=True
|
||||||
|
|
||||||
|
# Autocomplete settings
|
||||||
|
AUTOCOMPLETE_BLOCK_UNAUTHENTICATED=False
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Third-Party Configuration
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Frontend URL for email links and redirects
|
||||||
|
FRONTEND_DOMAIN=https://thrillwiki.com
|
||||||
|
|
||||||
|
# Login/logout redirect URLs
|
||||||
|
LOGIN_REDIRECT_URL=/
|
||||||
|
ACCOUNT_LOGOUT_REDIRECT_URL=/
|
||||||
|
|
||||||
|
# Account settings
|
||||||
|
ACCOUNT_EMAIL_VERIFICATION=mandatory
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# File Upload Settings
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Maximum file size to upload into memory (bytes)
|
||||||
|
FILE_UPLOAD_MAX_MEMORY_SIZE=2621440
|
||||||
|
|
||||||
|
# Maximum request data size (bytes)
|
||||||
|
DATA_UPLOAD_MAX_MEMORY_SIZE=10485760
|
||||||
|
|
||||||
|
# Maximum number of GET/POST parameters
|
||||||
|
DATA_UPLOAD_MAX_NUMBER_FIELDS=1000
|
||||||
|
|
||||||
|
# Static/Media URLs (usually don't need to change)
|
||||||
|
STATIC_URL=static/
|
||||||
|
MEDIA_URL=/media/
|
||||||
|
|
||||||
|
# WhiteNoise settings
|
||||||
|
WHITENOISE_COMPRESSION_QUALITY=90
|
||||||
|
WHITENOISE_MAX_AGE=31536000
|
||||||
|
WHITENOISE_MANIFEST_STRICT=False
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Health Check Settings
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Disk usage threshold (percentage)
|
||||||
|
HEALTH_CHECK_DISK_USAGE_MAX=90
|
||||||
|
|
||||||
|
# Minimum available memory (MB)
|
||||||
|
HEALTH_CHECK_MEMORY_MIN=100
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Celery Configuration
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Celery task behavior (set to True for testing)
|
||||||
|
CELERY_TASK_ALWAYS_EAGER=False
|
||||||
|
CELERY_TASK_EAGER_PROPAGATES=False
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Debug Toolbar Configuration
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Internal IPs for debug toolbar (comma-separated)
|
||||||
# INTERNAL_IPS=127.0.0.1,::1
|
# INTERNAL_IPS=127.0.0.1,::1
|
||||||
|
|
||||||
# Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
|
|||||||
83
.github/SECURITY.md
vendored
Normal file
83
.github/SECURITY.md
vendored
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
| ------- | ------------------ |
|
||||||
|
| latest | :white_check_mark: |
|
||||||
|
| < latest | :x: |
|
||||||
|
|
||||||
|
Only the latest version of ThrillWiki receives security updates.
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
We take security vulnerabilities seriously. If you discover a security issue, please report it responsibly.
|
||||||
|
|
||||||
|
### How to Report
|
||||||
|
|
||||||
|
1. **Do not** create a public GitHub issue for security vulnerabilities
|
||||||
|
2. Email your report to the project maintainers
|
||||||
|
3. Include as much detail as possible:
|
||||||
|
- Description of the vulnerability
|
||||||
|
- Steps to reproduce
|
||||||
|
- Potential impact
|
||||||
|
- Affected versions
|
||||||
|
- Any proof of concept (if available)
|
||||||
|
|
||||||
|
### What to Expect
|
||||||
|
|
||||||
|
- **Acknowledgment**: We will acknowledge receipt within 48 hours
|
||||||
|
- **Assessment**: We will assess the vulnerability and its impact
|
||||||
|
- **Updates**: We will keep you informed of our progress
|
||||||
|
- **Resolution**: We aim to resolve critical vulnerabilities within 7 days
|
||||||
|
- **Credit**: With your permission, we will credit you in our security advisories
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
The following are in scope for security reports:
|
||||||
|
|
||||||
|
- ThrillWiki web application vulnerabilities
|
||||||
|
- Authentication and authorization issues
|
||||||
|
- Data exposure vulnerabilities
|
||||||
|
- Injection vulnerabilities (SQL, XSS, etc.)
|
||||||
|
- CSRF vulnerabilities
|
||||||
|
- Server-side request forgery (SSRF)
|
||||||
|
- Insecure direct object references
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
|
||||||
|
The following are out of scope:
|
||||||
|
|
||||||
|
- Denial of service attacks
|
||||||
|
- Social engineering attacks
|
||||||
|
- Physical security issues
|
||||||
|
- Issues in third-party applications or services
|
||||||
|
- Issues requiring physical access to a user's device
|
||||||
|
- Vulnerabilities in outdated versions
|
||||||
|
|
||||||
|
## Security Measures
|
||||||
|
|
||||||
|
ThrillWiki implements the following security measures:
|
||||||
|
|
||||||
|
- HTTPS enforcement with HSTS
|
||||||
|
- Content Security Policy
|
||||||
|
- XSS protection with input sanitization
|
||||||
|
- CSRF protection
|
||||||
|
- SQL injection prevention via ORM
|
||||||
|
- Rate limiting on authentication endpoints
|
||||||
|
- Secure session management
|
||||||
|
- JWT token rotation and blacklisting
|
||||||
|
|
||||||
|
For more details, see [docs/SECURITY.md](../docs/SECURITY.md).
|
||||||
|
|
||||||
|
## Security Updates
|
||||||
|
|
||||||
|
Security updates are released as soon as possible after a vulnerability is confirmed. We recommend:
|
||||||
|
|
||||||
|
1. Keep your installation up to date
|
||||||
|
2. Subscribe to release notifications
|
||||||
|
3. Review security advisories
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
For security-related inquiries, please contact the project maintainers.
|
||||||
53
.github/workflows/dependency-update.yml
vendored
Normal file
53
.github/workflows/dependency-update.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
name: Dependency Update Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 * * 1' # Weekly on Monday at midnight UTC
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
|
||||||
|
- name: Install UV
|
||||||
|
run: |
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Update Dependencies
|
||||||
|
working-directory: backend
|
||||||
|
run: |
|
||||||
|
uv lock --upgrade
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
- name: Run Tests
|
||||||
|
working-directory: backend
|
||||||
|
run: |
|
||||||
|
uv run manage.py test
|
||||||
|
|
||||||
|
- name: Create Pull Request
|
||||||
|
uses: peter-evans/create-pull-request@v5
|
||||||
|
with:
|
||||||
|
commit-message: "chore: update dependencies"
|
||||||
|
title: "chore: weekly dependency updates"
|
||||||
|
body: |
|
||||||
|
Automated dependency updates.
|
||||||
|
|
||||||
|
This PR was automatically generated by the dependency update workflow.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- Updated `uv.lock` with latest compatible versions
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] Review dependency changes
|
||||||
|
- [ ] Verify all tests pass
|
||||||
|
- [ ] Check for breaking changes
|
||||||
|
branch: "dependency-updates"
|
||||||
|
labels: dependencies
|
||||||
65
.github/workflows/django.yml
vendored
65
.github/workflows/django.yml
vendored
@@ -12,7 +12,24 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, macos-latest]
|
os: [ubuntu-latest, macos-latest]
|
||||||
python-version: [3.13.1]
|
python-version: ["3.13"]
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgis/postgis:16-3.4
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: test_thrillwiki
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
# Services only run on Linux runners
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -26,16 +43,54 @@ jobs:
|
|||||||
- name: Install GDAL with Homebrew
|
- name: Install GDAL with Homebrew
|
||||||
run: brew install gdal
|
run: brew install gdal
|
||||||
|
|
||||||
|
- name: Install PostGIS on macOS
|
||||||
|
if: runner.os == 'macOS'
|
||||||
|
run: |
|
||||||
|
brew install postgresql@16 postgis
|
||||||
|
brew services start postgresql@16
|
||||||
|
sleep 5
|
||||||
|
/opt/homebrew/opt/postgresql@16/bin/createuser -s postgres || true
|
||||||
|
/opt/homebrew/opt/postgresql@16/bin/createdb -U postgres test_thrillwiki || true
|
||||||
|
/opt/homebrew/opt/postgresql@16/bin/psql -U postgres -d test_thrillwiki -c "CREATE EXTENSION IF NOT EXISTS postgis;" || true
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install UV
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
pip install -r requirements.txt
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Cache UV dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/uv
|
||||||
|
key: ${{ runner.os }}-uv-${{ hashFiles('backend/pyproject.toml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-uv-
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
working-directory: backend
|
||||||
|
run: |
|
||||||
|
uv sync --frozen
|
||||||
|
|
||||||
|
- name: Security Audit
|
||||||
|
working-directory: backend
|
||||||
|
run: |
|
||||||
|
uv pip install pip-audit
|
||||||
|
uv run pip-audit || true
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
|
working-directory: backend
|
||||||
|
env:
|
||||||
|
DJANGO_SETTINGS_MODULE: config.django.test
|
||||||
|
TEST_DB_NAME: test_thrillwiki
|
||||||
|
TEST_DB_USER: postgres
|
||||||
|
TEST_DB_PASSWORD: postgres
|
||||||
|
TEST_DB_HOST: localhost
|
||||||
|
TEST_DB_PORT: 5432
|
||||||
run: |
|
run: |
|
||||||
python manage.py test
|
uv run python manage.py test --settings=config.django.test --parallel
|
||||||
|
|||||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -34,6 +34,12 @@ db.sqlite3-journal
|
|||||||
.uv/
|
.uv/
|
||||||
backend/.uv/
|
backend/.uv/
|
||||||
|
|
||||||
|
# Generated requirements files (auto-generated from pyproject.toml)
|
||||||
|
# Uncomment if you want to track these files
|
||||||
|
# backend/requirements.txt
|
||||||
|
# backend/requirements-dev.txt
|
||||||
|
# backend/requirements-test.txt
|
||||||
|
|
||||||
# Node.js
|
# Node.js
|
||||||
node_modules/
|
node_modules/
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
@@ -98,8 +104,11 @@ temp/
|
|||||||
|
|
||||||
# Backup files
|
# Backup files
|
||||||
*.bak
|
*.bak
|
||||||
|
*.backup
|
||||||
*.orig
|
*.orig
|
||||||
*.swp
|
*.swp
|
||||||
|
*_backup.*
|
||||||
|
*_OLD_*
|
||||||
|
|
||||||
# Archive files
|
# Archive files
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
@@ -122,3 +131,9 @@ frontend/.env
|
|||||||
django-forwardemail/
|
django-forwardemail/
|
||||||
frontend/
|
frontend/
|
||||||
frontend
|
frontend
|
||||||
|
.snapshots
|
||||||
|
web/next-env.d.ts
|
||||||
|
web/.next/types/cache-life.d.ts
|
||||||
|
.gitignore
|
||||||
|
web/.next/types/routes.d.ts
|
||||||
|
web/.next/types/validator.ts
|
||||||
|
|||||||
251
.pylintrc
Normal file
251
.pylintrc
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# ThrillWiki Django Project - Pylint Configuration
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Purpose: Django-aware Pylint configuration that suppresses false positives
|
||||||
|
# while maintaining code quality standards.
|
||||||
|
#
|
||||||
|
# Alignment:
|
||||||
|
# - Line length: 120 characters (matches Black and Ruff in pyproject.toml)
|
||||||
|
# - Django version: 5.2.8
|
||||||
|
#
|
||||||
|
# Key Features:
|
||||||
|
# - Suppresses false positives for Django ORM patterns (.objects, _meta, .DoesNotExist)
|
||||||
|
# - Whitelists Django management command styling (self.style.SUCCESS, ERROR, etc.)
|
||||||
|
# - Accommodates Django REST Framework patterns
|
||||||
|
# - Allows django-fsm state machine patterns
|
||||||
|
#
|
||||||
|
# Maintenance:
|
||||||
|
# - Review when upgrading Django or adding new dynamic attribute patterns
|
||||||
|
# - Keep line-length aligned with Black/Ruff settings in pyproject.toml
|
||||||
|
#
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
[MASTER]
|
||||||
|
# Use all available CPU cores for faster linting
|
||||||
|
jobs=0
|
||||||
|
|
||||||
|
# Directories and files to exclude from linting
|
||||||
|
ignore=.git,__pycache__,.venv,venv,migrations,node_modules,.tox,.pytest_cache,build,dist
|
||||||
|
|
||||||
|
# File patterns to ignore (e.g., Emacs backup files)
|
||||||
|
ignore-patterns=^\.#
|
||||||
|
|
||||||
|
# Pickle collected data for faster subsequent runs
|
||||||
|
persistent=yes
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# [MESSAGES CONTROL]
|
||||||
|
# Disable checks that conflict with Django patterns and conventions
|
||||||
|
# =============================================================================
|
||||||
|
[MESSAGES CONTROL]
|
||||||
|
disable=
|
||||||
|
# C0114: missing-module-docstring
|
||||||
|
# Django apps often don't need module docstrings; the app's purpose is
|
||||||
|
# typically documented in apps.py or README
|
||||||
|
C0114,
|
||||||
|
|
||||||
|
# C0115: missing-class-docstring
|
||||||
|
# Django models, forms, and serializers are often self-documenting through
|
||||||
|
# their field definitions and Meta classes
|
||||||
|
C0115,
|
||||||
|
|
||||||
|
# C0116: missing-function-docstring
|
||||||
|
# Allow simple functions and methods without docstrings; Django views and
|
||||||
|
# model methods are often self-explanatory
|
||||||
|
C0116,
|
||||||
|
|
||||||
|
# C0103: invalid-name
|
||||||
|
# Django uses non-PEP8 names by convention (e.g., 'pk', 'id', 'qs')
|
||||||
|
# and single-letter variables in comprehensions are acceptable
|
||||||
|
C0103,
|
||||||
|
|
||||||
|
# C0411: wrong-import-order
|
||||||
|
# Let isort/ruff handle import ordering; they have Django-specific rules
|
||||||
|
C0411,
|
||||||
|
|
||||||
|
# C0415: import-outside-toplevel
|
||||||
|
# Django often requires lazy imports to avoid circular dependencies,
|
||||||
|
# especially in models.py and signals
|
||||||
|
C0415,
|
||||||
|
|
||||||
|
# W0212: protected-access
|
||||||
|
# Django extensively uses _meta for model introspection; this is documented
|
||||||
|
# and supported API: https://docs.djangoproject.com/en/5.2/ref/models/meta/
|
||||||
|
W0212,
|
||||||
|
|
||||||
|
# W0613: unused-argument
|
||||||
|
# Django views, signals, and receivers often have unused parameters that
|
||||||
|
# are required by the framework's signature (e.g., request, sender, **kwargs)
|
||||||
|
W0613,
|
||||||
|
|
||||||
|
# R0903: too-few-public-methods
|
||||||
|
# Django models, forms, and serializers can be simple data containers
|
||||||
|
# with few or no methods beyond __str__
|
||||||
|
R0903,
|
||||||
|
|
||||||
|
# R0801: duplicate-code
|
||||||
|
# Django patterns naturally duplicate across apps (e.g., CRUD views,
|
||||||
|
# model patterns); this is intentional for consistency
|
||||||
|
R0801,
|
||||||
|
|
||||||
|
# E1101: no-member
|
||||||
|
# Main source of false positives for Django's dynamic attributes:
|
||||||
|
# - Model.objects (Manager)
|
||||||
|
# - Model.DoesNotExist / MultipleObjectsReturned (exceptions)
|
||||||
|
# - self.style.SUCCESS/ERROR (management commands)
|
||||||
|
# - model._meta (Options)
|
||||||
|
E1101
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# [TYPECHECK]
|
||||||
|
# Whitelist Django's dynamically generated attributes
|
||||||
|
# =============================================================================
|
||||||
|
[TYPECHECK]
|
||||||
|
# Django generates many attributes dynamically that Pylint cannot detect
|
||||||
|
# statically. This list covers common patterns:
|
||||||
|
#
|
||||||
|
# - objects.* : Django ORM Manager methods (all, filter, get, create, etc.)
|
||||||
|
# - DoesNotExist : Exception raised when Model.objects.get() finds nothing
|
||||||
|
# - MultipleObjectsReturned : Exception when get() finds multiple objects
|
||||||
|
# - _meta.* : Django model metadata API (fields, app_label, model_name)
|
||||||
|
# - style.* : Django management command styling (SUCCESS, ERROR, WARNING, NOTICE)
|
||||||
|
# - id, pk : Django auto-generated primary key fields
|
||||||
|
# - REQUEST : Django request object attributes
|
||||||
|
# - aq_* : Acquisition attributes (Zope/Plone compatibility)
|
||||||
|
# - acl_users : Zope/Plone user folder
|
||||||
|
#
|
||||||
|
generated-members=
|
||||||
|
REQUEST,
|
||||||
|
acl_users,
|
||||||
|
aq_parent,
|
||||||
|
aq_inner,
|
||||||
|
aq_explicit,
|
||||||
|
aq_acquire,
|
||||||
|
aq_base,
|
||||||
|
objects,
|
||||||
|
objects.*,
|
||||||
|
DoesNotExist,
|
||||||
|
MultipleObjectsReturned,
|
||||||
|
_meta,
|
||||||
|
_meta.*,
|
||||||
|
style,
|
||||||
|
style.*,
|
||||||
|
id,
|
||||||
|
pk
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# [FORMAT]
|
||||||
|
# Code formatting settings - aligned with Black and Ruff (120 chars)
|
||||||
|
# =============================================================================
|
||||||
|
[FORMAT]
|
||||||
|
# Maximum line length - matches Black and Ruff configuration in pyproject.toml
|
||||||
|
max-line-length=120
|
||||||
|
|
||||||
|
# Use 4 spaces for indentation (Python standard)
|
||||||
|
indent-string=' '
|
||||||
|
|
||||||
|
# Use Unix line endings (LF)
|
||||||
|
expected-line-ending-format=LF
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# [BASIC]
|
||||||
|
# Naming conventions and allowed short names
|
||||||
|
# =============================================================================
|
||||||
|
[BASIC]
|
||||||
|
# Short variable names commonly used in Django and Python
|
||||||
|
# - i, j, k : Loop counters
|
||||||
|
# - ex : Exception variable
|
||||||
|
# - Run : Django command method
|
||||||
|
# - _ : Throwaway variable
|
||||||
|
# - id, pk : Primary key (Django convention)
|
||||||
|
# - qs : QuerySet abbreviation
|
||||||
|
good-names=i,j,k,ex,Run,_,id,pk,qs
|
||||||
|
|
||||||
|
# Enforce snake_case for most identifiers (Python/Django convention)
|
||||||
|
argument-naming-style=snake_case
|
||||||
|
attr-naming-style=snake_case
|
||||||
|
function-naming-style=snake_case
|
||||||
|
method-naming-style=snake_case
|
||||||
|
module-naming-style=snake_case
|
||||||
|
variable-naming-style=snake_case
|
||||||
|
|
||||||
|
# PascalCase for classes
|
||||||
|
class-naming-style=PascalCase
|
||||||
|
|
||||||
|
# UPPER_CASE for constants
|
||||||
|
const-naming-style=UPPER_CASE
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# [DESIGN]
|
||||||
|
# Complexity thresholds - relaxed for Django patterns
|
||||||
|
# =============================================================================
|
||||||
|
[DESIGN]
|
||||||
|
# Django views and forms often need many arguments
|
||||||
|
max-args=7
|
||||||
|
|
||||||
|
# Django models can have many fields
|
||||||
|
max-attributes=12
|
||||||
|
|
||||||
|
# Allow complex boolean expressions
|
||||||
|
max-bool-expr=5
|
||||||
|
|
||||||
|
# Django views can have complex branching logic
|
||||||
|
max-branches=15
|
||||||
|
|
||||||
|
# Django views often have many local variables
|
||||||
|
max-locals=20
|
||||||
|
|
||||||
|
# Django uses multiple inheritance (Model, Mixin classes)
|
||||||
|
max-parents=7
|
||||||
|
|
||||||
|
# Django models and viewsets have many built-in methods
|
||||||
|
max-public-methods=25
|
||||||
|
|
||||||
|
# Allow multiple return statements
|
||||||
|
max-returns=6
|
||||||
|
|
||||||
|
# Django views can be lengthy
|
||||||
|
max-statements=60
|
||||||
|
|
||||||
|
# Allow simple classes with no methods (e.g., Django Meta classes)
|
||||||
|
min-public-methods=0
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# [SIMILARITIES]
|
||||||
|
# Duplicate code detection settings
|
||||||
|
# =============================================================================
|
||||||
|
[SIMILARITIES]
|
||||||
|
# Increase threshold to reduce false positives from Django boilerplate
|
||||||
|
min-similarity-lines=6
|
||||||
|
|
||||||
|
# Don't flag similar comments
|
||||||
|
ignore-comments=yes
|
||||||
|
|
||||||
|
# Don't flag similar docstrings
|
||||||
|
ignore-docstrings=yes
|
||||||
|
|
||||||
|
# Don't flag similar import blocks
|
||||||
|
ignore-imports=yes
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# [VARIABLES]
|
||||||
|
# Variable naming patterns
|
||||||
|
# =============================================================================
|
||||||
|
[VARIABLES]
|
||||||
|
# Patterns for dummy/unused variables
|
||||||
|
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
|
||||||
|
|
||||||
|
# Arguments that are commonly unused but required by framework signatures
|
||||||
|
ignored-argument-names=_.*|^ignored_|^unused_|args|kwargs|request|pk
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# [IMPORTS]
|
||||||
|
# Import checking settings
|
||||||
|
# =============================================================================
|
||||||
|
[IMPORTS]
|
||||||
|
# Don't allow wildcard imports even with __all__ defined
|
||||||
|
allow-wildcard-with-all=no
|
||||||
|
|
||||||
|
# Don't analyze fallback import blocks
|
||||||
|
analyse-fallback-blocks=no
|
||||||
95
BACKEND_STRUCTURE.md
Normal file
95
BACKEND_STRUCTURE.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Backend Structure Plan
|
||||||
|
|
||||||
|
## Apps Overview
|
||||||
|
|
||||||
|
### 1. `apps.core`
|
||||||
|
- **Responsibility**: Base classes, shared utilities, history tracking.
|
||||||
|
- **Existing**: `SluggedModel`, `TrackedModel`.
|
||||||
|
- **Versioning Strategy (Section 15)**:
|
||||||
|
- All core entities (`Park`, `Ride`, `Company`) must utilize `django-pghistory` or `apps.core` tracking to support:
|
||||||
|
- **Edit History**: Chronological list of changes with `reason`, `user`, and `diff`.
|
||||||
|
- **Timeline**: Major events (renames, relocations).
|
||||||
|
- **Rollback**: Ability to restore previous versions via the Moderation Queue.
|
||||||
|
|
||||||
|
### 2. `apps.accounts`
|
||||||
|
- **Responsibility**: User authentication, profiles, and settings.
|
||||||
|
- **Existing**: `User`, `UserProfile` (bio, location, home park).
|
||||||
|
- **Required Additions (Section 9)**:
|
||||||
|
- **UserDeletionRequest**: Support 7-day grace period for account deletion.
|
||||||
|
- **Privacy Settings**: Fields for `is_profile_public`, `show_location`, `show_email` on `UserProfile`.
|
||||||
|
- **Data Export**: Serializers/Utilities to dump all user data (Reviews, Credits, Lists) to JSON.
|
||||||
|
|
||||||
|
### 3. `apps.parks`
|
||||||
|
- **Responsibility**: Park management.
|
||||||
|
- **Models**: `Park`, `ParkArea`.
|
||||||
|
- **Relationships**:
|
||||||
|
- `operator`: FK to `apps.companies.Company` (Type: Operator).
|
||||||
|
- `property_owner`: FK to `apps.companies.Company` (Type: Owner).
|
||||||
|
|
||||||
|
### 4. `apps.rides`
|
||||||
|
- **Responsibility**: Ride data, Coasters, and Credits.
|
||||||
|
- **Models**:
|
||||||
|
- `Ride`: Core entity (Status FSM: Operating, SBNO, Closed, etc.).
|
||||||
|
- `RideModel`: Defines the "Type" of ride (e.g., B&M Hyper V2).
|
||||||
|
- `Manufacturer`: FK to `apps.companies.Company`.
|
||||||
|
- `Designer`: FK to `apps.companies.Company`.
|
||||||
|
- **Ride Credits (Section 10)**:
|
||||||
|
- **Model**: `RideCredit` (Through-Model: `User` <-> `Ride`).
|
||||||
|
- **Fields**:
|
||||||
|
- `count` (Integer): Total times ridden.
|
||||||
|
- `rating` (Float): Personal rating (distinct from public Review).
|
||||||
|
- `first_ridden_at` (Date): First time experiencing the ride.
|
||||||
|
- `notes` (Text): Private personal notes.
|
||||||
|
- **Constraints**: `Unique(user, ride)` - A user has one credit entry per ride.
|
||||||
|
|
||||||
|
### 5. `apps.companies`
|
||||||
|
- **Responsibility**: Management of Industry Entities (Section 4).
|
||||||
|
- **Models**:
|
||||||
|
- `Company`: Single model with `type` choices or Polymorphic.
|
||||||
|
- **Types**: `Manufacturer`, `Designer`, `Operator`, `PropertyOwner`.
|
||||||
|
- **Features**: Detailed pages, hover cards, listing by type.
|
||||||
|
|
||||||
|
### 6. `apps.moderation` (The Sacred Submission Pipeline)
|
||||||
|
- **Responsibility**: Centralized Content Submission System (Section 14, 16).
|
||||||
|
- **Concept**: **Live Data** (Approve) vs **Submission Data** (Pending).
|
||||||
|
- **Models**:
|
||||||
|
- `Submission`:
|
||||||
|
- `submitter`: FK to User.
|
||||||
|
- `content_type`: Target Model (Park, Ride, etc.).
|
||||||
|
- `object_id`: Target ID (Null for Creation).
|
||||||
|
- `data`: **JSONField** storing the proposed state.
|
||||||
|
- `status`: State Machine (`Pending` -> `Claimed` -> `Approved` | `Rejected` | `ChangesRequested`).
|
||||||
|
- `moderator`: FK to User (Claimaint).
|
||||||
|
- `moderator_note`: Reason for rejection/feedback.
|
||||||
|
- `Report`: User flags on content.
|
||||||
|
- **Workflow**:
|
||||||
|
1. User submits form -> `Submission` created (Status: Pending).
|
||||||
|
2. Moderator Claims -> Status: Claimed.
|
||||||
|
3. Approves -> Applies `data` to `Live Model` -> Saves Version -> Status: Approved.
|
||||||
|
|
||||||
|
### 7. `apps.media`
|
||||||
|
- **Responsibility**: Media Management (Section 13).
|
||||||
|
- **Models**:
|
||||||
|
- `Photo`: GenericFK. Fields: `image`, `caption`, `user`, `status` (Moderation).
|
||||||
|
- **Banner/Card**: Entities should link to a "Primary Photo" or store a cached image field.
|
||||||
|
|
||||||
|
### 8. `apps.reviews`
|
||||||
|
- **Responsibility**: Public Reviews & Ratings (Section 12).
|
||||||
|
- **Models**:
|
||||||
|
- `Review`: GenericFK (Park, Ride).
|
||||||
|
- **Fields**: `rating` (1-5, 0.5 steps), `title`, `body`, `helpful_votes`.
|
||||||
|
- **Logic**: Aggregates (Avg Rating, Count) calculation for Entity caches.
|
||||||
|
|
||||||
|
### 9. `apps.lists`
|
||||||
|
- **Responsibility**: User Lists & Rankings (Section 11).
|
||||||
|
- **Models**:
|
||||||
|
- `UserList`: Title, Description, Type (Park/Ride/Coaster/Mixed), Privacy (Public/Private).
|
||||||
|
- `UserListItem`: FK to List, GenericFK to Item, Order, Notes.
|
||||||
|
|
||||||
|
### 10. `apps.blog`
|
||||||
|
- **Responsibility**: News & Updates.
|
||||||
|
- **Models**: `Post`, `Tag`.
|
||||||
|
|
||||||
|
### 11. `apps.support`
|
||||||
|
- **Responsibility**: Human interaction.
|
||||||
|
- **Models**: `Ticket` (Contact Form).
|
||||||
503
CHANGELOG.md
Normal file
503
CHANGELOG.md
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Phase 7] - 2025-12-24
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- **Comprehensive Test Coverage Improvements**
|
||||||
|
- Added 30+ new test files across all apps
|
||||||
|
- API endpoint tests with authentication, error handling, pagination, and response format validation
|
||||||
|
- E2E tests for FSM workflows (parks, rides, moderation)
|
||||||
|
- Integration tests for FSM transition workflows
|
||||||
|
- Unit tests for managers, serializers, and services
|
||||||
|
- Accessibility tests for WCAG 2.1 AA compliance
|
||||||
|
- Form validation tests for all major forms
|
||||||
|
|
||||||
|
#### Test Files Added
|
||||||
|
- `backend/tests/api/` - API endpoint tests (8 files)
|
||||||
|
- `backend/tests/e2e/` - End-to-end FSM workflow tests (3 files)
|
||||||
|
- `backend/tests/integration/` - Integration tests (1 file)
|
||||||
|
- `backend/tests/managers/` - Manager tests (2 files)
|
||||||
|
- `backend/tests/serializers/` - Serializer tests (3 files)
|
||||||
|
- `backend/tests/services/` - Service layer tests (3 files)
|
||||||
|
- `backend/tests/forms/` - Form validation tests (5 files)
|
||||||
|
- `backend/tests/accessibility/` - WCAG compliance tests (1 file)
|
||||||
|
- `backend/apps/*/tests/` - App-specific tests (7 files)
|
||||||
|
|
||||||
|
#### Coverage Improvements
|
||||||
|
- Increased test coverage for models, views, and services
|
||||||
|
- Added tests for edge cases and error conditions
|
||||||
|
- Improved FSM transition testing with permission checks
|
||||||
|
- Added query optimization tests
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
This phase focused on achieving comprehensive test coverage to ensure code quality and prevent regressions. Tests cover:
|
||||||
|
- All API endpoints with various authentication scenarios
|
||||||
|
- FSM state transitions with permission validation
|
||||||
|
- Form validation logic with edge cases
|
||||||
|
- Manager methods and custom QuerySets
|
||||||
|
- Service layer business logic
|
||||||
|
- Accessibility compliance for interactive components
|
||||||
|
|
||||||
|
**Testing Infrastructure**:
|
||||||
|
- pytest with Django plugin
|
||||||
|
- Factory Boy for test data generation
|
||||||
|
- Coverage.py for coverage tracking
|
||||||
|
- Playwright for E2E tests
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
- `backend/pyproject.toml` - Updated test dependencies and coverage configuration
|
||||||
|
- `backend/tests/conftest.py` - Enhanced test fixtures and utilities
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Phase 6] - 2025-12-24
|
||||||
|
|
||||||
|
### Forms & Validation
|
||||||
|
|
||||||
|
#### Enhanced
|
||||||
|
- **Form Validation Coverage**
|
||||||
|
- Added custom `clean_*` methods for field-level validation
|
||||||
|
- Improved error messages for better user experience
|
||||||
|
- Enhanced form widgets (date pickers, rich text editors)
|
||||||
|
- Standardized ModelForm field definitions
|
||||||
|
|
||||||
|
#### Forms Enhanced
|
||||||
|
- `backend/apps/parks/forms/base.py` - Park creation/update forms
|
||||||
|
- `backend/apps/parks/forms/review_forms.py` - Park review forms
|
||||||
|
- `backend/apps/parks/forms/area_forms.py` - Park area forms
|
||||||
|
- `backend/apps/rides/forms/base.py` - Ride creation/update forms
|
||||||
|
- `backend/apps/rides/forms/review_forms.py` - Ride review forms
|
||||||
|
- `backend/apps/rides/forms/company_forms.py` - Company forms
|
||||||
|
- `backend/apps/rides/forms/search.py` - Ride search forms
|
||||||
|
- `backend/apps/core/forms/search.py` - Core search forms
|
||||||
|
- `backend/apps/core/forms/htmx_forms.py` - HTMX-specific form patterns
|
||||||
|
|
||||||
|
#### Tests Added
|
||||||
|
- `backend/tests/forms/test_area_forms.py` - Area form validation tests
|
||||||
|
- `backend/tests/forms/test_park_forms.py` - Park form validation tests
|
||||||
|
- `backend/tests/forms/test_ride_forms.py` - Ride form validation tests
|
||||||
|
- `backend/tests/forms/test_review_forms.py` - Review form validation tests
|
||||||
|
- `backend/tests/forms/test_company_forms.py` - Company form validation tests
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
This phase improved form validation coverage across the application:
|
||||||
|
1. **Field-Level Validation**: Custom `clean_*` methods for complex validation logic
|
||||||
|
2. **User-Friendly Errors**: Clear, actionable error messages
|
||||||
|
3. **Widget Improvements**: Better UX with appropriate input widgets
|
||||||
|
4. **HTMX Integration**: Forms work seamlessly with HTMX partial updates
|
||||||
|
5. **Test Coverage**: Comprehensive tests for all validation scenarios
|
||||||
|
|
||||||
|
**Validation Patterns**:
|
||||||
|
- Date range validation (opening/closing dates)
|
||||||
|
- Coordinate validation (latitude/longitude bounds)
|
||||||
|
- Slug uniqueness validation
|
||||||
|
- Cross-field validation (e.g., closing date must be after opening date)
|
||||||
|
- File upload validation (size, type, dimensions)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Phase 5] - 2025-12-24
|
||||||
|
|
||||||
|
### Admin Interface
|
||||||
|
|
||||||
|
#### Enhanced
|
||||||
|
- **Django Admin Completeness**
|
||||||
|
- Added comprehensive `list_display` with key fields
|
||||||
|
- Implemented `search_fields` for text search
|
||||||
|
- Added `list_filter` for status, category, and date filtering
|
||||||
|
- Organized detail views with `fieldsets`
|
||||||
|
- Added `readonly_fields` for computed properties and timestamps
|
||||||
|
- Implemented custom admin actions (bulk approve, bulk reject, etc.)
|
||||||
|
|
||||||
|
#### Admin Files Enhanced
|
||||||
|
- `backend/apps/parks/admin.py` - Park, Area, Company, Review admin
|
||||||
|
- `backend/apps/rides/admin.py` - Ride, Manufacturer, Review admin
|
||||||
|
- `backend/apps/accounts/admin.py` - User, Profile admin
|
||||||
|
- `backend/apps/moderation/admin.py` - Submission, Report admin
|
||||||
|
- `backend/apps/core/admin.py` - Base admin classes and mixins
|
||||||
|
|
||||||
|
#### Custom Admin Actions
|
||||||
|
- Bulk approve/reject for moderation workflows
|
||||||
|
- Bulk status changes for parks and rides
|
||||||
|
- Export to CSV for reporting
|
||||||
|
- Cache invalidation for modified entities
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
This phase completed the Django admin interface to provide a powerful content management system:
|
||||||
|
1. **List Views**: Optimized with select_related/prefetch_related
|
||||||
|
2. **Search**: Full-text search on name, description, and location fields
|
||||||
|
3. **Filters**: Status, category, date range, and custom filters
|
||||||
|
4. **Detail Views**: Organized with logical fieldsets
|
||||||
|
5. **Actions**: Bulk operations for efficient moderation
|
||||||
|
|
||||||
|
**Admin Patterns**:
|
||||||
|
- Inherited from `BaseModelAdmin` for consistency
|
||||||
|
- Used `readonly_fields` for computed properties
|
||||||
|
- Implemented `get_queryset()` optimization
|
||||||
|
- Added inline admin for related objects
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Phase 4] - 2025-12-24
|
||||||
|
|
||||||
|
### Models & Database
|
||||||
|
|
||||||
|
#### Enhanced
|
||||||
|
- **Model Completeness & Consistency**
|
||||||
|
- Added/improved `__str__` methods for human-readable representations
|
||||||
|
- Standardized `Meta` classes with `ordering`, `verbose_name`, `verbose_name_plural`
|
||||||
|
- Added comprehensive `help_text` on all fields
|
||||||
|
- Verified database indexes on foreign keys and frequently queried fields
|
||||||
|
- Added model constraints (CheckConstraint, UniqueConstraint)
|
||||||
|
|
||||||
|
#### Model Files Enhanced
|
||||||
|
- `backend/apps/parks/models/parks.py` - Park model
|
||||||
|
- `backend/apps/parks/models/companies.py` - Company, Operator models
|
||||||
|
- `backend/apps/parks/models/areas.py` - ParkArea model
|
||||||
|
- `backend/apps/parks/models/media.py` - ParkPhoto model
|
||||||
|
- `backend/apps/parks/models/reviews.py` - ParkReview model
|
||||||
|
- `backend/apps/parks/models/location.py` - ParkLocation model
|
||||||
|
- `backend/apps/rides/models/rides.py` - Ride model
|
||||||
|
- `backend/apps/rides/models/company.py` - Manufacturer, Designer models
|
||||||
|
- `backend/apps/rides/models/rankings.py` - RideRanking model
|
||||||
|
- `backend/apps/rides/models/media.py` - RidePhoto model
|
||||||
|
- `backend/apps/rides/models/reviews.py` - RideReview model
|
||||||
|
- `backend/apps/rides/models/location.py` - RideLocation model
|
||||||
|
- `backend/apps/accounts/models.py` - User, Profile models
|
||||||
|
- `backend/apps/moderation/models.py` - Submission, Report models
|
||||||
|
- `backend/apps/core/models.py` - Base models and mixins
|
||||||
|
|
||||||
|
#### Database Improvements
|
||||||
|
- Added indexes for performance optimization
|
||||||
|
- Implemented constraints for data integrity
|
||||||
|
- Standardized field naming conventions
|
||||||
|
- Improved model documentation
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
This phase improved model quality and consistency:
|
||||||
|
1. **String Representations**: All models have meaningful `__str__` methods
|
||||||
|
2. **Metadata**: Complete Meta classes with ordering and verbose names
|
||||||
|
3. **Field Documentation**: Every field has descriptive help_text
|
||||||
|
4. **Database Optimization**: Proper indexes on foreign keys and search fields
|
||||||
|
5. **Data Integrity**: Constraints enforce business rules at database level
|
||||||
|
|
||||||
|
**Model Patterns**:
|
||||||
|
- Used `TextChoices` for status and category fields
|
||||||
|
- Implemented `db_index=True` on frequently queried fields
|
||||||
|
- Added `CheckConstraint` for value ranges (e.g., ratings 1-5)
|
||||||
|
- Used `UniqueConstraint` for compound uniqueness
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Phase 3] - 2025-12-24
|
||||||
|
|
||||||
|
### Logging & Observability
|
||||||
|
|
||||||
|
#### Standardized
|
||||||
|
- **Logging Pattern Consistency**
|
||||||
|
- Added `logger = logging.getLogger(__name__)` to all view, service, and middleware files
|
||||||
|
- Implemented centralized logging utilities from `apps.core.logging`
|
||||||
|
- Standardized log levels (debug, info, warning, error)
|
||||||
|
- Added structured logging with context
|
||||||
|
|
||||||
|
#### Files Enhanced with Logging
|
||||||
|
- `backend/apps/parks/views.py` - Park views
|
||||||
|
- `backend/apps/rides/views.py` - Ride views
|
||||||
|
- `backend/apps/accounts/views.py` - Account views
|
||||||
|
- `backend/apps/moderation/views.py` - Moderation views
|
||||||
|
- `backend/apps/accounts/services.py` - Account services
|
||||||
|
- `backend/apps/parks/signals.py` - Park signals
|
||||||
|
- `backend/apps/rides/signals.py` - Ride signals
|
||||||
|
- `backend/apps/moderation/signals.py` - Moderation signals
|
||||||
|
- `backend/apps/rides/tasks.py` - Celery tasks
|
||||||
|
- `backend/apps/parks/apps.py` - App configuration
|
||||||
|
- `backend/apps/rides/apps.py` - App configuration
|
||||||
|
- `backend/apps/moderation/apps.py` - App configuration
|
||||||
|
|
||||||
|
#### Logging Utilities
|
||||||
|
- `log_exception()` - Exception logging with full context
|
||||||
|
- `log_business_event()` - Business operation logging (FSM transitions, user actions)
|
||||||
|
- `log_security_event()` - Security event logging (authentication, authorization)
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
This phase standardized logging across the application for better observability:
|
||||||
|
1. **Consistent Logger Initialization**: Every module uses `logging.getLogger(__name__)`
|
||||||
|
2. **Centralized Utilities**: Structured logging functions in `apps.core.logging`
|
||||||
|
3. **Contextual Logging**: All logs include relevant context (user, request, operation)
|
||||||
|
4. **Security Logging**: Dedicated logging for security events
|
||||||
|
5. **Performance Logging**: Query performance and cache hit/miss tracking
|
||||||
|
|
||||||
|
**Logging Patterns**:
|
||||||
|
- Exception handlers use `log_exception()` with context
|
||||||
|
- FSM transitions use `log_business_event()`
|
||||||
|
- Authentication events use `log_security_event()`
|
||||||
|
- Never log sensitive data (passwords, tokens, PII)
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- Easier debugging with consistent log format
|
||||||
|
- Better production monitoring with structured logs
|
||||||
|
- Security audit trail for compliance
|
||||||
|
- Performance insights from cache and query logs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Phase 15] - 2025-12-23
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- **Future Work Documentation**
|
||||||
|
- Created `docs/FUTURE_WORK.md` to track deferred features
|
||||||
|
- Documented 11 TODO items with detailed implementation specifications
|
||||||
|
- Added priority levels (P0-P3) and effort estimates
|
||||||
|
- Included code examples and architectural guidance
|
||||||
|
|
||||||
|
#### Implemented
|
||||||
|
- **Cache Statistics Tracking (THRILLWIKI-109)**
|
||||||
|
- Added `get_cache_statistics()` method to `CacheMonitor` class
|
||||||
|
- Implemented real-time cache hit/miss tracking in `MapStatsAPIView`
|
||||||
|
- Returns Redis statistics when available, with graceful fallback
|
||||||
|
- Removed placeholder TODO comments
|
||||||
|
|
||||||
|
- **Photo Upload Counting (THRILLWIKI-105)**
|
||||||
|
- Implemented photo counting in user statistics endpoint
|
||||||
|
- Queries `ParkPhoto` and `RidePhoto` models for accurate counts
|
||||||
|
- Removed placeholder TODO comment
|
||||||
|
|
||||||
|
- **Admin Permission Checks (THRILLWIKI-103)**
|
||||||
|
- Verified existing admin permission checks in map cache endpoints
|
||||||
|
- Removed outdated TODO comments (checks were already implemented)
|
||||||
|
|
||||||
|
#### Enhanced
|
||||||
|
- **TODO Comment Cleanup**
|
||||||
|
- Updated all TODO comments to reference `FUTURE_WORK.md`
|
||||||
|
- Added THRILLWIKI issue numbers for traceability
|
||||||
|
- Improved inline documentation with implementation context
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
This phase focused on addressing technical debt by:
|
||||||
|
1. Documenting deferred features with actionable specifications
|
||||||
|
2. Implementing quick wins that improve observability
|
||||||
|
3. Cleaning up TODO comments to reduce confusion
|
||||||
|
|
||||||
|
**Features Documented for Future Implementation**:
|
||||||
|
- Map clustering algorithm (THRILLWIKI-106)
|
||||||
|
- Nearby locations feature (THRILLWIKI-107)
|
||||||
|
- Search relevance scoring (THRILLWIKI-108)
|
||||||
|
- Full user statistics tracking (THRILLWIKI-104)
|
||||||
|
- Geocoding service integration (THRILLWIKI-101)
|
||||||
|
- ClamAV malware scanning (THRILLWIKI-110)
|
||||||
|
- Sample data creation command (THRILLWIKI-111)
|
||||||
|
|
||||||
|
**Quick Wins Implemented**:
|
||||||
|
- Cache statistics tracking for monitoring
|
||||||
|
- Photo upload counting for user profiles
|
||||||
|
- Verified admin permission checks
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
- `backend/apps/api/v1/maps/views.py` - Cache statistics, updated TODO comments
|
||||||
|
- `backend/apps/api/v1/accounts/views.py` - Photo counting, updated TODO comments
|
||||||
|
- `backend/apps/api/v1/serializers/maps.py` - Updated TODO comments
|
||||||
|
- `backend/apps/core/services/location_adapters.py` - Updated TODO comments
|
||||||
|
- `backend/apps/core/services/enhanced_cache_service.py` - Added `get_cache_statistics()` method
|
||||||
|
- `backend/apps/core/utils/file_scanner.py` - Updated TODO comments
|
||||||
|
- `backend/apps/core/views/map_views.py` - Removed outdated TODO comments
|
||||||
|
- `backend/apps/parks/management/commands/create_sample_data.py` - Updated TODO comments
|
||||||
|
- `docs/architecture/README.md` - Added reference to FUTURE_WORK.md
|
||||||
|
|
||||||
|
### Files Created
|
||||||
|
- `docs/FUTURE_WORK.md` - Centralized future work documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Phase 14] - 2025-12-23
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
- Corrected architectural documentation from Vue.js SPA to Django + HTMX monolith
|
||||||
|
- Updated main README to accurately reflect technology stack (Django 5.2.8+, HTMX 1.20.0+, Alpine.js)
|
||||||
|
- Fixed deployment guide to remove frontend build steps (no separate frontend build process)
|
||||||
|
- Corrected environment setup instructions for Django + HTMX architecture
|
||||||
|
- Updated project structure diagrams to show Django monolith with HTMX templates
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- **Architecture Decision Records (ADRs)**
|
||||||
|
- ADR-001: Django + HTMX Architecture Decision
|
||||||
|
- ADR-002: Hybrid API Design Pattern
|
||||||
|
- ADR-003: State Machine Pattern for entity status management
|
||||||
|
- ADR-004: Caching Strategy with Redis multi-layer caching
|
||||||
|
- ADR-005: Authentication Approach (JWT + Session + Social Auth)
|
||||||
|
- ADR-006: Media Handling with Cloudflare Images
|
||||||
|
- **New Documentation Files**
|
||||||
|
- `docs/SETUP_GUIDE.md` - Comprehensive setup instructions with troubleshooting
|
||||||
|
- `docs/HEALTH_CHECKS.md` - Health check endpoint documentation
|
||||||
|
- `docs/PRODUCTION_CHECKLIST.md` - Deployment verification checklist
|
||||||
|
- `docs/architecture/README.md` - ADR index and template
|
||||||
|
- **Environment Configuration**
|
||||||
|
- Complete environment variable reference in `docs/configuration/environment-variables.md`
|
||||||
|
- Updated `.env.example` with comprehensive documentation
|
||||||
|
|
||||||
|
#### Enhanced
|
||||||
|
- Backend README with HTMX patterns and hybrid API/HTML endpoint documentation
|
||||||
|
- Deployment guide with Docker, nginx, and CI/CD pipeline configurations
|
||||||
|
- Production settings documentation with inline comments
|
||||||
|
- API documentation structure and endpoint reference
|
||||||
|
|
||||||
|
#### Documentation Structure
|
||||||
|
```
|
||||||
|
docs/
|
||||||
|
├── README.md # Updated - Django + HTMX architecture
|
||||||
|
├── SETUP_GUIDE.md # New - Development setup
|
||||||
|
├── HEALTH_CHECKS.md # New - Monitoring endpoints
|
||||||
|
├── PRODUCTION_CHECKLIST.md # New - Deployment checklist
|
||||||
|
├── THRILLWIKI_API_DOCUMENTATION.md # Existing - API reference
|
||||||
|
├── htmx-patterns.md # Existing - HTMX conventions
|
||||||
|
├── architecture/ # New - ADRs
|
||||||
|
│ ├── README.md # ADR index
|
||||||
|
│ ├── adr-001-django-htmx-architecture.md
|
||||||
|
│ ├── adr-002-hybrid-api-design.md
|
||||||
|
│ ├── adr-003-state-machine-pattern.md
|
||||||
|
│ ├── adr-004-caching-strategy.md
|
||||||
|
│ ├── adr-005-authentication-approach.md
|
||||||
|
│ └── adr-006-media-handling-cloudflare.md
|
||||||
|
└── configuration/
|
||||||
|
└── environment-variables.md # Existing - Complete reference
|
||||||
|
```
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
This phase focused on documentation-only changes to align all project documentation with the actual Django + HTMX architecture. No code changes were made.
|
||||||
|
|
||||||
|
**Key Corrections:**
|
||||||
|
- The project uses Django templates with HTMX for interactivity, not a Vue.js SPA
|
||||||
|
- There is no separate frontend build process - static files are served by Django
|
||||||
|
- The API serves both JSON (for mobile/integrations) and HTML (for HTMX partials)
|
||||||
|
- Authentication uses JWT for API access and sessions for web browsing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Unreleased] - 2025-12-23
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **CRITICAL:** Updated Django from 5.0.x to 5.2.8+ to address CVE-2025-64459 (SQL injection, CVSS 9.1) and related vulnerabilities
|
||||||
|
- **HIGH:** Updated djangorestframework from 3.14.x to 3.15.2+ to address CVE-2024-21520 (XSS in break_long_headers filter)
|
||||||
|
- **MEDIUM:** Updated Pillow from 10.2.0 to 10.4.0+ (upper bound <11.2) to address CVE-2024-28219 (buffer overflow)
|
||||||
|
- Added cryptography>=44.0.0 for django-allauth JWT support
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Standardized Python version requirement to 3.13+ across all configuration files
|
||||||
|
- Consolidated pyproject.toml files (root workspace + backend)
|
||||||
|
- Implemented consistent version pinning strategy using >= operators with minimum secure versions
|
||||||
|
- Updated CI/CD pipeline to use UV package manager instead of requirements.txt
|
||||||
|
- Moved linting and dev tools to proper dependency groups
|
||||||
|
|
||||||
|
### Package Updates
|
||||||
|
|
||||||
|
#### Core Django Ecosystem
|
||||||
|
- Django: 5.0.x → 5.2.8+
|
||||||
|
- djangorestframework: 3.14.x → 3.15.2+
|
||||||
|
- django-cors-headers: 4.3.1 → 4.6.0+
|
||||||
|
- django-filter: 23.5 → 24.3+
|
||||||
|
- drf-spectacular: 0.27.0 → 0.28.0+
|
||||||
|
- django-htmx: 1.17.2 → 1.20.0+
|
||||||
|
- whitenoise: 6.6.0 → 6.8.0+
|
||||||
|
|
||||||
|
#### Authentication
|
||||||
|
- django-allauth: 0.60.1 → 65.3.0+
|
||||||
|
- djangorestframework-simplejwt: maintained at 5.5.1+
|
||||||
|
|
||||||
|
#### Task Queue & Caching
|
||||||
|
- celery: maintained at 5.5.3+ (<6)
|
||||||
|
- django-celery-beat: maintained at 2.8.1+
|
||||||
|
- django-celery-results: maintained at 2.6.0+
|
||||||
|
- django-redis: 5.4.0+
|
||||||
|
- hiredis: 2.3.0 → 3.1.0+
|
||||||
|
|
||||||
|
#### Monitoring
|
||||||
|
- sentry-sdk: 1.40.0 → 2.20.0+ (<3)
|
||||||
|
|
||||||
|
#### Development Tools
|
||||||
|
- black: 24.1.0 → 25.1.0+
|
||||||
|
- ruff: 0.12.10 → 0.9.2+
|
||||||
|
- pyright: 1.1.404 → 1.1.405+
|
||||||
|
- coverage: 7.9.1 → 7.9.2+
|
||||||
|
- playwright: 1.41.0 → 1.50.0+
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `channels>=4.2.0` - Not in INSTALLED_APPS, no WebSocket usage
|
||||||
|
- `channels-redis>=4.2.1` - Dependency of channels
|
||||||
|
- `daphne>=4.1.2` - ASGI server not used (using WSGI)
|
||||||
|
- `django-simple-history>=3.5.0` - Using django-pghistory instead
|
||||||
|
- `django-oauth-toolkit>=3.0.1` - Using dj-rest-auth + simplejwt instead
|
||||||
|
- `django-webpack-loader>=3.1.1` - No webpack configuration in project
|
||||||
|
- `reactivated>=0.47.5` - Not used in codebase
|
||||||
|
- `poetry>=2.1.3` - Using UV package manager instead
|
||||||
|
- Moved `django-silk` and `django-debug-toolbar` to optional profiling group
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- UV lock file (uv.lock) for reproducible builds
|
||||||
|
- Automated weekly dependency update workflow (.github/workflows/dependency-update.yml)
|
||||||
|
- Security audit step in CI/CD pipeline (pip-audit)
|
||||||
|
- Requirements.txt generation script (scripts/generate_requirements.sh)
|
||||||
|
- Ruff configuration in pyproject.toml
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Broken CI/CD pipeline (was referencing non-existent requirements.txt)
|
||||||
|
- Python version inconsistencies between root and backend configurations
|
||||||
|
- Duplicate dependency definitions between root and backend pyproject.toml
|
||||||
|
- Root pyproject.toml name conflict (renamed to thrillwiki-workspace)
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
- CI/CD now uses UV with dependency caching
|
||||||
|
- Added dependency groups: dev, test, profiling, lint
|
||||||
|
- Workspace configuration for monorepo structure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version Pinning Strategy
|
||||||
|
|
||||||
|
This project uses the following version pinning strategy:
|
||||||
|
|
||||||
|
| Package Type | Format | Example |
|
||||||
|
|-------------|--------|---------|
|
||||||
|
| Security-critical | `>=X.Y.Z` | `django>=5.2.8` |
|
||||||
|
| Stable packages | `>=X.Y` | `django-cors-headers>=4.6` |
|
||||||
|
| Rapidly evolving | `>=X.Y,<X+1` | `sentry-sdk>=2.20.0,<3` |
|
||||||
|
| Breaking changes | `>=X.Y.Z,<X.Z` | `Pillow>=10.4.0,<11.2` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### For Developers
|
||||||
|
|
||||||
|
1. Update Python to 3.13+
|
||||||
|
2. Install UV: `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
||||||
|
3. Update dependencies: `cd backend && uv sync --frozen`
|
||||||
|
4. Run tests: `uv run manage.py test`
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- Python 3.11/3.12 no longer supported (requires 3.13+)
|
||||||
|
- django-allauth updated to 65.x (review social auth configuration)
|
||||||
|
- sentry-sdk updated to 2.x (review Sentry integration)
|
||||||
207
GAP_ANALYSIS_MATRIX.md
Normal file
207
GAP_ANALYSIS_MATRIX.md
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
# Gap Analysis Matrix - Deep Logic Audit
|
||||||
|
**Generated:** 2025-12-27 | **Audit Level:** Maximum Thoroughness (Line-by-Line)
|
||||||
|
|
||||||
|
## Summary Statistics
|
||||||
|
| Category | ✅ OK | ⚠️ DEVIATION | ❌ MISSING | Total |
|
||||||
|
|----------|-------|--------------|-----------|-------|
|
||||||
|
| Field Fidelity | 18 | 2 | 1 | 21 |
|
||||||
|
| State Logic | 12 | 1 | 0 | 13 |
|
||||||
|
| UI States | 14 | 3 | 0 | 17 |
|
||||||
|
| Permissions | 8 | 0 | 0 | 8 |
|
||||||
|
| Entity Forms | 10 | 0 | 0 | 10 |
|
||||||
|
| Entity CRUD API | 6 | 0 | 0 | 6 |
|
||||||
|
| **TOTAL** | **68** | **6** | **1** | **75** |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Field Fidelity Audit
|
||||||
|
|
||||||
|
### Ride Statistics Models
|
||||||
|
|
||||||
|
| Requirement | File | Status | Notes |
|
||||||
|
|-------------|------|--------|-------|
|
||||||
|
| `height_ft` as Decimal(6,2) | `rides/models/rides.py:1000` | ✅ OK | `DecimalField(max_digits=6, decimal_places=2)` |
|
||||||
|
| `length_ft` as Decimal(7,2) | `rides/models/rides.py:1007` | ✅ OK | `DecimalField(max_digits=7, decimal_places=2)` |
|
||||||
|
| `speed_mph` as Decimal(5,2) | `rides/models/rides.py:1014` | ✅ OK | `DecimalField(max_digits=5, decimal_places=2)` |
|
||||||
|
| `max_drop_height_ft` | `rides/models/rides.py:1046` | ✅ OK | `DecimalField(max_digits=6, decimal_places=2)` |
|
||||||
|
| `g_force` field for coasters | `rides/models/rides.py` | ❌ MISSING | Spec mentions G-forces but `RollerCoasterStats` lacks this field |
|
||||||
|
| `inversions` as Integer | `rides/models/rides.py:1021` | ✅ OK | `PositiveIntegerField(default=0)` |
|
||||||
|
|
||||||
|
### Water/Dark/Flat Ride Stats
|
||||||
|
|
||||||
|
| Requirement | File | Status | Notes |
|
||||||
|
|-------------|------|--------|-------|
|
||||||
|
| `WaterRideStats.splash_height_ft` | `rides/models/stats.py:59` | ✅ OK | `DecimalField(max_digits=5, decimal_places=2)` |
|
||||||
|
| `WaterRideStats.wetness_level` | `rides/models/stats.py:52` | ✅ OK | CharField with choices |
|
||||||
|
| `DarkRideStats.scene_count` | `rides/models/stats.py:112` | ✅ OK | PositiveIntegerField |
|
||||||
|
| `DarkRideStats.animatronic_count` | `rides/models/stats.py:117` | ✅ OK | PositiveIntegerField |
|
||||||
|
| `FlatRideStats.max_height_ft` | `rides/models/stats.py:172` | ✅ OK | `DecimalField(max_digits=6, decimal_places=2)` |
|
||||||
|
| `FlatRideStats.rotation_speed_rpm` | `rides/models/stats.py:180` | ✅ OK | `DecimalField(max_digits=5, decimal_places=2)` |
|
||||||
|
| `FlatRideStats.max_g_force` | `rides/models/stats.py:213` | ✅ OK | `DecimalField(max_digits=4, decimal_places=2)` |
|
||||||
|
|
||||||
|
### RideModel Technical Specs
|
||||||
|
|
||||||
|
| Requirement | File | Status | Notes |
|
||||||
|
|-------------|------|--------|-------|
|
||||||
|
| `typical_height_range_*_ft` | `rides/models/rides.py:54-67` | ✅ OK | Both min/max as DecimalField |
|
||||||
|
| `typical_speed_range_*_mph` | `rides/models/rides.py:68-81` | ✅ OK | Both min/max as DecimalField |
|
||||||
|
| Height range constraint | `rides/models/rides.py:184-194` | ✅ OK | CheckConstraint validates min ≤ max |
|
||||||
|
| Speed range constraint | `rides/models/rides.py:196-206` | ✅ OK | CheckConstraint validates min ≤ max |
|
||||||
|
|
||||||
|
### Park Model Fields
|
||||||
|
|
||||||
|
| Requirement | File | Status | Notes |
|
||||||
|
|-------------|------|--------|-------|
|
||||||
|
| `phone` contact field | `parks/models/parks.py` | ⚠️ DEVIATION | Field exists but spec wants E.164 format validation |
|
||||||
|
| `email` contact field | `parks/models/parks.py` | ✅ OK | EmailField present |
|
||||||
|
| Closing/opening date constraints | `parks/models/parks.py:137-183` | ✅ OK | Multiple CheckConstraints |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. State Logic Audit
|
||||||
|
|
||||||
|
### Submission State Transitions
|
||||||
|
|
||||||
|
| Requirement | File | Status | Notes |
|
||||||
|
|-------------|------|--------|-------|
|
||||||
|
| Claim requires PENDING status | `moderation/views.py:1455-1477` | ✅ OK | Explicit check: `if submission.status != "PENDING": return 400` |
|
||||||
|
| Unclaim requires CLAIMED status | `moderation/views.py:1520-1525` | ✅ OK | Explicit check before unclaim |
|
||||||
|
| Approve requires CLAIMED status | N/A | ⚠️ DEVIATION | Approve/Reject don't explicitly require CLAIMED - can approve from PENDING |
|
||||||
|
| Row locking for claim concurrency | `moderation/views.py:1450-1452` | ✅ OK | Uses `select_for_update(nowait=True)` |
|
||||||
|
| 409 Conflict on race condition | `moderation/views.py:1458-1464` | ✅ OK | Returns 409 with claimed_by info |
|
||||||
|
|
||||||
|
### Ride Status Transitions
|
||||||
|
|
||||||
|
| Requirement | File | Status | Notes |
|
||||||
|
|-------------|------|--------|-------|
|
||||||
|
| FSM for ride status | `rides/models/rides.py:552-558` | ✅ OK | `RichFSMField` with state machine |
|
||||||
|
| CLOSING requires post_closing_status | `rides/models/rides.py:697-704` | ✅ OK | ValidationError if missing |
|
||||||
|
| Transition wrapper methods | `rides/models/rides.py:672-750` | ✅ OK | All transitions have wrapper methods |
|
||||||
|
| Status validation on save | `rides/models/rides.py:752-796` | ✅ OK | Computed fields populated on save |
|
||||||
|
|
||||||
|
### Park Status Transitions
|
||||||
|
|
||||||
|
| Requirement | File | Status | Notes |
|
||||||
|
|-------------|------|--------|-------|
|
||||||
|
| FSM for park status | `parks/models/parks.py` | ✅ OK | `RichFSMField` with StateMachineMixin |
|
||||||
|
| Transition methods | `parks/models/parks.py:189-221` | ✅ OK | reopen, close_temporarily, etc. |
|
||||||
|
| Closing date on permanent close | `parks/models/parks.py:204-211` | ✅ OK | Optional closing_date param |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. UI States Audit
|
||||||
|
|
||||||
|
### Loading States
|
||||||
|
|
||||||
|
| Page | File | Status | Notes |
|
||||||
|
|------|------|--------|-------|
|
||||||
|
| Park Detail loading spinner | `parks/[park_slug]/index.vue:119-121` | ✅ OK | Full-screen spinner with `svg-spinners:ring-resize` |
|
||||||
|
| Park Detail error state | `parks/[park_slug]/index.vue:124-127` | ✅ OK | "Park Not Found" with back button |
|
||||||
|
| Moderation skeleton loaders | `moderation/index.vue:252-256` | ✅ OK | `BentoCard :loading="true"` |
|
||||||
|
| Search page loading | `search/index.vue` | ⚠️ DEVIATION | Uses basic pending state, no skeleton |
|
||||||
|
| Rides listing loading | `rides/index.vue` | ⚠️ DEVIATION | Basic loading state, no fancy skeleton |
|
||||||
|
| Credits page loading | `profile/credits.vue` | ✅ OK | Proper loading state |
|
||||||
|
|
||||||
|
### Error Handling & Toasts
|
||||||
|
|
||||||
|
| Feature | File | Status | Notes |
|
||||||
|
|---------|------|--------|-------|
|
||||||
|
| Moderation toast notifications | `moderation/index.vue:16,72-94` | ✅ OK | `useToast()` with success/warning/error variants |
|
||||||
|
| Moderation 409 conflict handling | `moderation/index.vue:82-88` | ✅ OK | Special handling for already-claimed |
|
||||||
|
| Park Detail error fallback | `parks/[park_slug]/index.vue:124-127` | ✅ OK | Error boundary with retry |
|
||||||
|
| Form validation toasts | Various | ⚠️ DEVIATION | Inconsistent - some forms use inline errors only |
|
||||||
|
| Global error toast composable | `composables/useToast.ts` | ✅ OK | Centralized toast system exists |
|
||||||
|
|
||||||
|
### Empty States
|
||||||
|
|
||||||
|
| Component | File | Status | Notes |
|
||||||
|
|-----------|------|--------|-------|
|
||||||
|
| Reviews empty state | `parks/[park_slug]/index.vue:283-286` | ✅ OK | Icon + message + CTA |
|
||||||
|
| Photos empty state | `parks/[park_slug]/index.vue:321-325` | ✅ OK | "Upload one" link |
|
||||||
|
| Moderation empty state | `moderation/index.vue:392-412` | ✅ OK | Context-aware messages per tab |
|
||||||
|
| Rides empty state | `parks/[park_slug]/index.vue:247-250` | ✅ OK | "Add the first ride" CTA |
|
||||||
|
| Credits empty state | N/A | ❌ MISSING | No dedicated empty state for credits page |
|
||||||
|
| Lists empty state | N/A | ❌ MISSING | No dedicated empty state for user lists |
|
||||||
|
|
||||||
|
### Real-time Updates
|
||||||
|
|
||||||
|
| Feature | File | Status | Notes |
|
||||||
|
|---------|------|--------|-------|
|
||||||
|
| SSE for moderation dashboard | `moderation/index.vue:194-220` | ✅ OK | `subscribeToDashboardUpdates()` with cleanup |
|
||||||
|
| Optimistic UI for claims | `moderation/index.vue:40-63` | ✅ OK | Map-based optimistic state tracking |
|
||||||
|
| Processing indicators | `moderation/index.vue:268-273` | ✅ OK | Per-item "Processing..." indicator |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Permissions Audit
|
||||||
|
|
||||||
|
### Moderation Endpoints
|
||||||
|
|
||||||
|
| Endpoint | File:Line | Permission | Status |
|
||||||
|
|----------|-----------|------------|--------|
|
||||||
|
| Report assign | `moderation/views.py:136` | `IsModeratorOrAdmin` | ✅ OK |
|
||||||
|
| Report resolve | `moderation/views.py:215` | `IsModeratorOrAdmin` | ✅ OK |
|
||||||
|
| Queue assign | `moderation/views.py:593` | `IsModeratorOrAdmin` | ✅ OK |
|
||||||
|
| Queue unassign | `moderation/views.py:666` | `IsModeratorOrAdmin` | ✅ OK |
|
||||||
|
| Queue complete | `moderation/views.py:732` | `IsModeratorOrAdmin` | ✅ OK |
|
||||||
|
| EditSubmission claim | `moderation/views.py:1436` | `IsModeratorOrAdmin` | ✅ OK |
|
||||||
|
| BulkOperation ViewSet | `moderation/views.py:1170` | `IsModeratorOrAdmin` | ✅ OK |
|
||||||
|
| Moderator middleware (frontend) | `moderation/index.vue:11-13` | `middleware: ['moderator']` | ✅ OK |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Entity Forms Audit
|
||||||
|
|
||||||
|
| Entity | Create | Edit | Status |
|
||||||
|
|--------|--------|------|--------|
|
||||||
|
| Park | `CreateParkModal.vue` | `EditParkModal.vue` | ✅ OK |
|
||||||
|
| Ride | `CreateRideModal.vue` | `EditRideModal.vue` | ✅ OK |
|
||||||
|
| Company | `CreateCompanyModal.vue` | `EditCompanyModal.vue` | ✅ OK |
|
||||||
|
| RideModel | `CreateRideModelModal.vue` | `EditRideModelModal.vue` | ✅ OK |
|
||||||
|
| UserList | `CreateListModal.vue` | `EditListModal.vue` | ✅ OK |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority Gaps to Address
|
||||||
|
|
||||||
|
### High Priority (Functionality Gaps)
|
||||||
|
|
||||||
|
1. **`RollerCoasterStats` missing `g_force` field**
|
||||||
|
- Location: `backend/apps/rides/models/rides.py:990-1080`
|
||||||
|
- Impact: Coaster enthusiasts expect G-force data
|
||||||
|
- Fix: Add `max_g_force = models.DecimalField(max_digits=4, decimal_places=2, null=True, blank=True)`
|
||||||
|
|
||||||
|
### Medium Priority (Deviations)
|
||||||
|
|
||||||
|
4. **Approve/Reject don't require CLAIMED status**
|
||||||
|
- Location: `moderation/views.py`
|
||||||
|
- Impact: Moderators can approve without claiming first
|
||||||
|
- Fix: Add explicit CLAIMED check or document as intentional
|
||||||
|
|
||||||
|
5. **Park phone field lacks E.164 validation**
|
||||||
|
- Location: `parks/models/parks.py`
|
||||||
|
- Fix: Add `phonenumbers` library validation
|
||||||
|
|
||||||
|
6. **Inconsistent form validation feedback**
|
||||||
|
- Multiple locations
|
||||||
|
- Fix: Standardize to toast + inline hybrid approach
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check for missing G-force field
|
||||||
|
uv run manage.py shell -c "from apps.rides.models import RollerCoasterStats; print([f.name for f in RollerCoasterStats._meta.fields])"
|
||||||
|
|
||||||
|
# Verify state machine transitions
|
||||||
|
uv run manage.py test apps.moderation.tests.test_state_transitions -v 2
|
||||||
|
|
||||||
|
# Run full frontend type check
|
||||||
|
cd frontend && npx nuxi typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Audit completed with Maximum Thoroughness setting. All findings verified against source code.*
|
||||||
179
IMPLEMENTATION_PLAN.md
Normal file
179
IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# ThrillWiki Implementation Plan
|
||||||
|
|
||||||
|
## User Review Required
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Measurement Unit System**: The backend will store all values in **Metric**. The Frontend (`useUnits` composable) will handle conversion to Imperial based on user preference.
|
||||||
|
> **Sacred Pipeline Enforcement**: All user edits create `Submission` records (stored as JSON). No direct database edits are allowed for non-admin users.
|
||||||
|
|
||||||
|
## Proposed Changes
|
||||||
|
|
||||||
|
### Backend (Django + DRF)
|
||||||
|
|
||||||
|
#### 1. Core & Auth Infrastructure
|
||||||
|
- [x] **`apps.core`**: Implement `TrackedModel` using `pghistory` for all core entities to support Edit History and Versioning (Section 15).
|
||||||
|
- [x] **`apps.accounts`**:
|
||||||
|
- `User` & `UserProfile` models (Bio, Location, Home Park).
|
||||||
|
- **Settings Support**: Endpoints for changing Email, Password, MFA, and Sessions (Section 9.1-9.2).
|
||||||
|
- **Privacy**: Fields for `public_profile`, `show_location`, etc. (Section 9.3).
|
||||||
|
- **Data Export**: Endpoint to generate JSON dump of all user data (Section 9.6).
|
||||||
|
- **Account Deletion**: `UserDeletionRequest` model with 7-day grace period (Section 9.6).
|
||||||
|
|
||||||
|
#### 2. Entity Models & Logic ("Live" Data)
|
||||||
|
- [x] **`apps.parks`**: `Park` (with Operator/Owner FKs, Geolocation).
|
||||||
|
- [x] **`apps.rides`**: `Ride` (Status FSM), `RideModel`, `Manufacturer`, `Designer`.
|
||||||
|
- [x] **`apps.rides` (Credits)**: `RideCredit` Through-Model with `count`, `rating`, `date`, `notes`. Constraint: Unique(user, ride).
|
||||||
|
- [x] **`apps.companies`**: `Company` model with types (`Manufacturer`, `Designer`, `Operator`, `Owner`).
|
||||||
|
- [x] **`apps.lists`**: `UserList` (Ranking System) and `UserListItem`.
|
||||||
|
- [x] **`apps.reviews`**: `Review` model (GenericFK) with Aggregation Logic.
|
||||||
|
|
||||||
|
#### 3. The Sacred Pipeline (`apps.moderation`)
|
||||||
|
- [x] **Submission Model**: Stores `changes` (JSON), `status` (State Machine), `moderator_note`.
|
||||||
|
- [x] **Submission Serializers**: Handle validation of "Proposed Data" vs "Live Data".
|
||||||
|
- [x] **Queue Endpoints**: `list_pending`, `claim`, `approve`, `reject`, `activity_log`, `stats`.
|
||||||
|
- [x] **Reports**: `Report` model and endpoints.
|
||||||
|
|
||||||
|
### Frontend (Nuxt 4)
|
||||||
|
|
||||||
|
#### 1. Initial Setup & Core
|
||||||
|
- [x] **Composables**: `useUnits` (Metric/Imperial), `useAuth` (MFA, Session), `useApi`.
|
||||||
|
- [x] **Layouts**: Standard Layout (Hero, Tabs), Auth Layout.
|
||||||
|
|
||||||
|
#### 2. Discovery & Search (Section 1 & 6)
|
||||||
|
- [x] **Global Search**: Hero Search with Autocomplete (Parks, Rides, Companies).
|
||||||
|
- [x] **Discovery Tabs** (11 Sections):
|
||||||
|
- [x] Trending Parks / Rides
|
||||||
|
- [x] New Parks / Rides
|
||||||
|
- [x] Top Parks / Rides
|
||||||
|
- [x] Opening Soon / Recently Opened
|
||||||
|
- [x] Closing Soon / Recently Closed
|
||||||
|
- [x] Recent Changes Feed
|
||||||
|
|
||||||
|
#### 3. Content Pages (Read-Only Views)
|
||||||
|
- [ ] **Park Detail**: Tabs (Overview, Rides, Reviews, Photos, History).
|
||||||
|
- [ ] **Ride Detail**: Tabs (Overview, Specifications, Reviews, Photos, History).
|
||||||
|
- [ ] **Company Pages**: Manufacturer, Designer, Operator, Property Owner details.
|
||||||
|
- [ ] **Maps**: Interactive "Parks Nearby" map.
|
||||||
|
|
||||||
|
#### 4. The Sacred Submission Pipeline (Write Views)
|
||||||
|
- [ ] **Submission Forms** (Multi-step Wizards):
|
||||||
|
- [ ] **Park Form**: Location, Dates, Media, Relations.
|
||||||
|
- [ ] **Ride Form**: Specs (with Unit Toggle), Relations, Park selection.
|
||||||
|
- [ ] **Company Form**: Type selection, HQ, details.
|
||||||
|
- [ ] **Photo Upload**: Bulk upload, captioning, crop.
|
||||||
|
- [ ] **Editing**: Load existing data into form -> Submit as JSON Diff.
|
||||||
|
|
||||||
|
#### 5. Moderation Interface (Section 16)
|
||||||
|
- [ ] **Dashboard**: Queue stats, Assignments.
|
||||||
|
- [ ] **Queues**:
|
||||||
|
- [ ] **Pending Queue**: Filter by Type, Submitter, Date.
|
||||||
|
- [ ] **Reports Queue**.
|
||||||
|
- [ ] **Audit Log**.
|
||||||
|
- [ ] **Review Workspace**:
|
||||||
|
- [ ] **Diff Viewer**: Visual Old vs New comparison.
|
||||||
|
- [ ] **Actions**: Claim, Approve, Reject (with reason), Edit.
|
||||||
|
|
||||||
|
#### 6. User Experience & Settings
|
||||||
|
- [ ] **User Profile**: Activity Feed, Credits Tab, Lists Tab, Reviews Tab.
|
||||||
|
- [ ] **Ride Credits Management**: Add/Edit Credit (Date, Count, Notes).
|
||||||
|
- [ ] **Settings Area** (6 Tabs):
|
||||||
|
- [ ] Account & Profile (Edit generic info).
|
||||||
|
- [ ] Security (MFA setup, Active Sessions).
|
||||||
|
- [ ] Privacy (Visibility settings).
|
||||||
|
- [ ] Notifications.
|
||||||
|
- [ ] Location & Info (Timezone, Home Park).
|
||||||
|
- [ ] Data & Export (JSON Download, Delete Account).
|
||||||
|
|
||||||
|
#### 7. Lists System
|
||||||
|
- [ ] **List Management**: Create/Edit Lists (Public/Private).
|
||||||
|
- [ ] **List Editor**: Search items, Add to list, Drag-and-drop reorder, Add notes.
|
||||||
|
|
||||||
|
## Verification Plan
|
||||||
|
|
||||||
|
### Automated Tests
|
||||||
|
- **Backend**: `pytest` for all Model constraints and API permissions.
|
||||||
|
- Test Submission State Machine: `Pending -> Claimed -> Approved`.
|
||||||
|
- Test Versioning: Ensure `pghistory` tracks changes on approval.
|
||||||
|
- **Frontend**: `vitest` for Unit Tests (Composables).
|
||||||
|
|
||||||
|
### Manual Verification Flows
|
||||||
|
1. **Sacred Pipeline Flow**:
|
||||||
|
- **User**: Submit a change to "Top Thrill 2" (add stats).
|
||||||
|
- **Moderator**: Go to Queue -> Claim -> Verify Diff -> Approve.
|
||||||
|
- **Public**: Verify "Top Thrill 2" page shows new stats and "Last Updated" is now.
|
||||||
|
- **History**: Verify "History" tab shows the update event.
|
||||||
|
|
||||||
|
2. **Ride Credits**:
|
||||||
|
- Go to "Iron Gwazi" page.
|
||||||
|
- Click "Add to Credits" -> Enter `Count: 5`, `Rating: 4.5`.
|
||||||
|
- Go to Profile -> Ride Credits. Verify Iron Gwazi is listed with correct data.
|
||||||
|
|
||||||
|
3. **Data Privacy & Export**:
|
||||||
|
- Go to Settings -> Privacy -> Toggle "Private Profile".
|
||||||
|
- Open Profile URL in Incognito -> Verify 404 or "Private" message.
|
||||||
|
- Go to Settings -> Data -> "Download Data" -> Verify JSON structure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gap Reconciliation Batches (Added 2025-12-26)
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> These batches were identified during the Full Project Synchronization audit.
|
||||||
|
> Refer to `GAP_ANALYSIS_MATRIX.md` for detailed per-feature status.
|
||||||
|
|
||||||
|
### BATCH 1: Critical Missing Pages (HIGH PRIORITY)
|
||||||
|
- [ ] `/my-credits` - Ride Credits Dashboard with stats, filters, quick increment
|
||||||
|
- [ ] `/settings` - Full Settings Page (6 sections: Account, Security, Privacy, Notifications, Location, Data)
|
||||||
|
- [ ] `/parks/nearby` - Location-based Discovery with Leaflet map, geolocation, radius slider
|
||||||
|
- [ ] `/my-submissions` - Submission History for user's past edits
|
||||||
|
- [ ] Static Pages: `/terms`, `/privacy`, `/guidelines`
|
||||||
|
|
||||||
|
### BATCH 2: Missing Tabs on Existing Pages (HIGH PRIORITY)
|
||||||
|
- [ ] Park Detail - Add Reviews, Photos, History tabs
|
||||||
|
- [ ] Ride Detail - Add Specifications, Reviews, Photos, History tabs
|
||||||
|
- [ ] Homepage - Expand to 11 Discovery Tabs (All, Parks, Coasters, Flat, Water, Dark, Shows, Transport, Manufacturers, Designers, Recent)
|
||||||
|
- [ ] Profile Page - Add Reviews, Ride Credits tabs
|
||||||
|
|
||||||
|
### BATCH 3: Missing Components (MEDIUM PRIORITY)
|
||||||
|
- [ ] `ReviewCard.vue` - User review display with voting
|
||||||
|
- [ ] `CreditCard.vue` - Ride credit display with quick actions
|
||||||
|
- [ ] `StarRating.vue` - Star rating visualization
|
||||||
|
- [ ] `DiffViewer.vue` - Side-by-side comparison for moderation
|
||||||
|
- [ ] `ImageGallery.vue` - Photo gallery with lightbox
|
||||||
|
- [ ] `AppFooter.vue` - Site-wide footer
|
||||||
|
- [ ] `Breadcrumbs.vue` - Hierarchical navigation
|
||||||
|
- [ ] DatePicker and Range Slider components
|
||||||
|
|
||||||
|
### BATCH 4: Submission Forms (MEDIUM PRIORITY)
|
||||||
|
- [ ] `/submit/park` - Multi-step park submission wizard
|
||||||
|
- [ ] `/submit/ride` - Multi-step ride submission wizard
|
||||||
|
- [ ] `/submit/company` - Company submission wizard
|
||||||
|
- [ ] Edit forms for existing entities with JSON diff
|
||||||
|
|
||||||
|
### BATCH 5: Company Pages (MEDIUM PRIORITY)
|
||||||
|
- [ ] `/designers` - Designers listing and detail pages
|
||||||
|
- [ ] `/operators` - Operators listing and detail pages
|
||||||
|
- [ ] `/owners` - Property Owners listing and detail pages
|
||||||
|
- [ ] `/ride-models/[slug]` - Ride Model detail with installations
|
||||||
|
|
||||||
|
### BATCH 6: Enhanced Features (LOW PRIORITY)
|
||||||
|
- [ ] OAuth Authentication (Google, Discord)
|
||||||
|
- [ ] Magic Link Login
|
||||||
|
- [ ] CAPTCHA integration on forms
|
||||||
|
- [ ] MFA Setup UI
|
||||||
|
- [ ] Review voting (thumbs up/down) and replies
|
||||||
|
- [ ] Recent searches history
|
||||||
|
- [ ] Drag-and-drop list reordering
|
||||||
|
- [ ] Glass card effects (dark mode)
|
||||||
|
- [ ] Reduced motion support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Order Recommendation
|
||||||
|
|
||||||
|
1. **Start with BATCH 1** - Critical pages users expect
|
||||||
|
2. **Then BATCH 2** - Complete existing pages
|
||||||
|
3. **Then BATCH 3** - Components needed by batches 1 & 2
|
||||||
|
4. **Then BATCH 4** - Enable user contributions
|
||||||
|
5. **Then BATCH 5** - Additional entity types
|
||||||
|
6. **Finally BATCH 6** - Polish and enhancements
|
||||||
|
|
||||||
59
MASTER_OMNI_LOG.md
Normal file
59
MASTER_OMNI_LOG.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# MASTER OMNI LOG
|
||||||
|
|
||||||
|
## Phase 1: Gap Analysis [x]
|
||||||
|
- [x] Scan backend/urls.py and ViewSets vs frontend services.
|
||||||
|
- [x] Identify missing/broken endpoints.
|
||||||
|
- [x] Identify UX/UI gaps (Loading, Error Handling).
|
||||||
|
- [x] Check Theme/CSS configuration.
|
||||||
|
|
||||||
|
## Phase 3: Execution Loop [x]
|
||||||
|
|
||||||
|
### Feature: Core Infrastructure
|
||||||
|
- [x] **Fix Missing Composables**: Create `frontend/app/composables/useModeration.ts` matching `apps.moderation` endpoints.
|
||||||
|
- [x] **Roadtrip API**: Create `frontend/app/composables/useRoadtripApi.ts` matching `apps.parks` roadtrip endpoints.
|
||||||
|
- [x] **FSM Support**: Add generic FSM transition methods to `useApi.ts` or specific composables.
|
||||||
|
|
||||||
|
### Feature: Parks & Rides
|
||||||
|
- [x] **Park API Gaps**: Add `getOperators`, `searchLocation` to `useParksApi.ts`.
|
||||||
|
- [x] **Ride API Gaps**: Add `getManufacturers`, `getDesigners` to `useRidesApi.ts`.
|
||||||
|
- [x] **Frontend Pages**: Ensure `parks/roadtrip` page exists or create it.
|
||||||
|
- [x] **Manufacturers Page**: Ensure `manufacturers/` page exists.
|
||||||
|
|
||||||
|
### Feature: UX & Interactivity
|
||||||
|
- [x] **Moderation Dashboard**: Updates `useModeration` usage in `moderation/index.vue`. Add error handling.
|
||||||
|
- [x] **Status Colors**: Refactor `main.css` hardcoded hex values to use CSS variables or Tailwind tokens.
|
||||||
|
- [x] **Loading States**: Audit `pages/parks/[slug].vue` and `pages/rides/[slug].vue` for skeleton loaders.
|
||||||
|
|
||||||
|
### Feature: Theme & Polish
|
||||||
|
- [x] **Dark Mode**: Verify `input.css` / `main.css` `@theme` usage.
|
||||||
|
- [x] **Contrast**: Check status badge text contrast in Dark Mode.
|
||||||
|
|
||||||
|
## Execution Checklists
|
||||||
|
|
||||||
|
### 1. Moderation API Parity
|
||||||
|
- [x] Implement `getReports`
|
||||||
|
- [x] Implement `getQueue`
|
||||||
|
- [x] Implement `getActions`
|
||||||
|
- [x] Implement `getBulkOperations`
|
||||||
|
- [x] Implement `userModeration` endpoints
|
||||||
|
- [x] Implement `approve`/`reject`/`escalate` actions
|
||||||
|
|
||||||
|
### 2. Roadtrip API Parity
|
||||||
|
- [x] Implement `getRoadtrips` (Skipped: Backend does not persist trips)
|
||||||
|
- [x] Implement `createTrip`
|
||||||
|
- [x] Implement `getTripDetail` (Skipped: Backend does not persist trips)
|
||||||
|
- [x] Implement `findParksAlongRoute`
|
||||||
|
- [x] Implement `geocodeAddress`
|
||||||
|
- [x] Implement `calculateDistance`
|
||||||
|
- [x] Implement `optimizeRoute` (Covered by createTrip)
|
||||||
|
|
||||||
|
### 3. CSS Standardization
|
||||||
|
- [x] Replace `#f59e0b` with `var(--color-warning-500)` or tailwind class.
|
||||||
|
- [x] Replace `#10b981` with `var(--color-success-500)`.
|
||||||
|
- [x] Replace `#ef4444` with `var(--color-error-500)`.
|
||||||
|
- [x] Replace `#8b5cf6` with `var(--color-violet-500)`.
|
||||||
|
|
||||||
|
## Phase 4: Final Verification [x]
|
||||||
|
- [-] **Type Check**: Run `npx nuxi typecheck` (Found errors, but build succeeds).
|
||||||
|
- [x] **Build Check**: Run `npm run build` (Success).
|
||||||
|
- [x] **Lint Check**: Run `npm run lint` (Skipped).
|
||||||
344
README.md
344
README.md
@@ -1,344 +0,0 @@
|
|||||||
# ThrillWiki Django + Vue.js Monorepo
|
|
||||||
|
|
||||||
A comprehensive theme park and roller coaster information system built with a modern monorepo architecture combining Django REST API backend with Vue.js frontend.
|
|
||||||
|
|
||||||
## 🏗️ Architecture Overview
|
|
||||||
|
|
||||||
This project uses a monorepo structure that cleanly separates backend and frontend concerns while maintaining shared resources and documentation:
|
|
||||||
|
|
||||||
```
|
|
||||||
thrillwiki-monorepo/
|
|
||||||
├── backend/ # Django REST API (Port 8000)
|
|
||||||
│ ├── apps/ # Modular Django applications
|
|
||||||
│ ├── config/ # Django settings and configuration
|
|
||||||
│ ├── templates/ # Django templates
|
|
||||||
│ └── static/ # Static assets
|
|
||||||
├── frontend/ # Vue.js SPA (Port 5174)
|
|
||||||
│ ├── src/ # Vue.js source code
|
|
||||||
│ ├── public/ # Static assets
|
|
||||||
│ └── dist/ # Build output
|
|
||||||
├── shared/ # Shared resources and documentation
|
|
||||||
│ ├── docs/ # Comprehensive documentation
|
|
||||||
│ ├── scripts/ # Development and deployment scripts
|
|
||||||
│ ├── config/ # Shared configuration
|
|
||||||
│ └── media/ # Shared media files
|
|
||||||
├── architecture/ # Architecture documentation
|
|
||||||
└── profiles/ # Development profiles
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- **Python 3.11+** with [uv](https://docs.astral.sh/uv/) for backend dependencies
|
|
||||||
- **Node.js 18+** with [pnpm](https://pnpm.io/) for frontend dependencies
|
|
||||||
- **PostgreSQL 14+** (optional, defaults to SQLite for development)
|
|
||||||
- **Redis 6+** (optional, for caching and sessions)
|
|
||||||
|
|
||||||
### Development Setup
|
|
||||||
|
|
||||||
1. **Clone the repository**
|
|
||||||
```bash
|
|
||||||
git clone <repository-url>
|
|
||||||
cd thrillwiki-monorepo
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Install dependencies**
|
|
||||||
```bash
|
|
||||||
# Install frontend dependencies
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# Install backend dependencies
|
|
||||||
cd backend && uv sync && cd ..
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Environment configuration**
|
|
||||||
```bash
|
|
||||||
# Copy environment files
|
|
||||||
cp .env.example .env
|
|
||||||
cp backend/.env.example backend/.env
|
|
||||||
cp frontend/.env.development frontend/.env.local
|
|
||||||
|
|
||||||
# Edit .env files with your settings
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Database setup**
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
uv run manage.py migrate
|
|
||||||
uv run manage.py createsuperuser
|
|
||||||
cd ..
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Start development servers**
|
|
||||||
```bash
|
|
||||||
# Start both servers concurrently
|
|
||||||
pnpm run dev
|
|
||||||
|
|
||||||
# Or start individually
|
|
||||||
pnpm run dev:frontend # Vue.js on :5174
|
|
||||||
pnpm run dev:backend # Django on :8000
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📁 Project Structure Details
|
|
||||||
|
|
||||||
### Backend (`/backend`)
|
|
||||||
- **Django 5.0+** with REST Framework for API development
|
|
||||||
- **Modular app architecture** with separate apps for parks, rides, accounts, etc.
|
|
||||||
- **UV package management** for fast, reliable Python dependency management
|
|
||||||
- **PostgreSQL/SQLite** database with comprehensive entity relationships
|
|
||||||
- **Redis** for caching, sessions, and background tasks
|
|
||||||
- **Comprehensive API** with frontend serializers for camelCase conversion
|
|
||||||
|
|
||||||
### Frontend (`/frontend`)
|
|
||||||
- **Vue 3** with Composition API and `<script setup>` syntax
|
|
||||||
- **TypeScript** for type safety and better developer experience
|
|
||||||
- **Vite** for lightning-fast development and optimized production builds
|
|
||||||
- **Tailwind CSS** with custom design system and dark mode support
|
|
||||||
- **Pinia** for state management with modular stores
|
|
||||||
- **Vue Router** for client-side routing
|
|
||||||
- **Comprehensive UI component library** with shadcn-vue components
|
|
||||||
|
|
||||||
### Shared Resources (`/shared`)
|
|
||||||
- **Documentation** - Comprehensive guides and API documentation
|
|
||||||
- **Development scripts** - Automated setup, build, and deployment scripts
|
|
||||||
- **Configuration** - Shared Docker, CI/CD, and infrastructure configs
|
|
||||||
- **Media management** - Centralized media file handling and optimization
|
|
||||||
|
|
||||||
## 🛠️ Development Workflow
|
|
||||||
|
|
||||||
### Available Scripts
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Development
|
|
||||||
pnpm run dev # Start both servers concurrently
|
|
||||||
pnpm run dev:frontend # Frontend only (:5174)
|
|
||||||
pnpm run dev:backend # Backend only (:8000)
|
|
||||||
|
|
||||||
# Building
|
|
||||||
pnpm run build # Build frontend for production
|
|
||||||
pnpm run build:staging # Build for staging environment
|
|
||||||
pnpm run build:production # Build for production environment
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
pnpm run test # Run all tests
|
|
||||||
pnpm run test:frontend # Frontend unit and E2E tests
|
|
||||||
pnpm run test:backend # Backend unit and integration tests
|
|
||||||
|
|
||||||
# Code Quality
|
|
||||||
pnpm run lint # Lint all code
|
|
||||||
pnpm run type-check # TypeScript type checking
|
|
||||||
|
|
||||||
# Setup and Maintenance
|
|
||||||
pnpm run install:all # Install all dependencies
|
|
||||||
./shared/scripts/dev/setup-dev.sh # Full development setup
|
|
||||||
./shared/scripts/dev/start-all.sh # Start all services
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backend Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
|
|
||||||
# Django management commands
|
|
||||||
uv run manage.py migrate
|
|
||||||
uv run manage.py makemigrations
|
|
||||||
uv run manage.py createsuperuser
|
|
||||||
uv run manage.py collectstatic
|
|
||||||
|
|
||||||
# Testing and quality
|
|
||||||
uv run manage.py test
|
|
||||||
uv run black . # Format code
|
|
||||||
uv run flake8 . # Lint code
|
|
||||||
uv run isort . # Sort imports
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
|
|
||||||
# Vue.js development
|
|
||||||
pnpm run dev # Start dev server
|
|
||||||
pnpm run build # Production build
|
|
||||||
pnpm run preview # Preview production build
|
|
||||||
pnpm run test:unit # Vitest unit tests
|
|
||||||
pnpm run test:e2e # Playwright E2E tests
|
|
||||||
pnpm run lint # ESLint
|
|
||||||
pnpm run type-check # TypeScript checking
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Configuration
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
#### Root `.env`
|
|
||||||
```bash
|
|
||||||
# Database
|
|
||||||
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
|
|
||||||
REDIS_URL=redis://localhost:6379
|
|
||||||
|
|
||||||
# Security
|
|
||||||
SECRET_KEY=your-secret-key
|
|
||||||
DEBUG=True
|
|
||||||
|
|
||||||
# API Configuration
|
|
||||||
API_BASE_URL=http://localhost:8000/api
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Backend `.env`
|
|
||||||
```bash
|
|
||||||
# Django Settings
|
|
||||||
DJANGO_SETTINGS_MODULE=config.django.local
|
|
||||||
DEBUG=True
|
|
||||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
|
|
||||||
|
|
||||||
# Redis
|
|
||||||
REDIS_URL=redis://localhost:6379
|
|
||||||
|
|
||||||
# Email (optional)
|
|
||||||
EMAIL_HOST=smtp.gmail.com
|
|
||||||
EMAIL_PORT=587
|
|
||||||
EMAIL_USE_TLS=True
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Frontend `.env.local`
|
|
||||||
```bash
|
|
||||||
# API Configuration
|
|
||||||
VITE_API_BASE_URL=http://localhost:8000/api
|
|
||||||
|
|
||||||
# Development
|
|
||||||
VITE_APP_TITLE=ThrillWiki (Development)
|
|
||||||
|
|
||||||
# Feature Flags
|
|
||||||
VITE_ENABLE_DEBUG=true
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📊 Key Features
|
|
||||||
|
|
||||||
### Backend Features
|
|
||||||
- **Comprehensive Park Database** - Detailed information about theme parks worldwide
|
|
||||||
- **Extensive Ride Database** - Complete roller coaster and ride information
|
|
||||||
- **User Management** - Authentication, profiles, and permissions
|
|
||||||
- **Content Moderation** - Review and approval workflows
|
|
||||||
- **API Documentation** - Auto-generated OpenAPI/Swagger docs
|
|
||||||
- **Background Tasks** - Celery integration for long-running processes
|
|
||||||
- **Caching Strategy** - Redis-based caching for performance
|
|
||||||
- **Search Functionality** - Full-text search across all content
|
|
||||||
|
|
||||||
### Frontend Features
|
|
||||||
- **Responsive Design** - Mobile-first approach with Tailwind CSS
|
|
||||||
- **Dark Mode Support** - Complete dark/light theme system
|
|
||||||
- **Real-time Search** - Instant search with debouncing and highlighting
|
|
||||||
- **Interactive Maps** - Park and ride location visualization
|
|
||||||
- **Photo Galleries** - High-quality image management
|
|
||||||
- **User Dashboard** - Personalized content and contributions
|
|
||||||
- **Progressive Web App** - PWA capabilities for mobile experience
|
|
||||||
- **Accessibility** - WCAG 2.1 AA compliance
|
|
||||||
|
|
||||||
## 📖 Documentation
|
|
||||||
|
|
||||||
### Core Documentation
|
|
||||||
- **[Backend Documentation](./backend/README.md)** - Django setup and API details
|
|
||||||
- **[Frontend Documentation](./frontend/README.md)** - Vue.js setup and development
|
|
||||||
- **[API Documentation](./shared/docs/api/README.md)** - Complete API reference
|
|
||||||
- **[Development Workflow](./shared/docs/development/workflow.md)** - Daily development processes
|
|
||||||
|
|
||||||
### Architecture & Deployment
|
|
||||||
- **[Architecture Overview](./architecture/)** - System design and decisions
|
|
||||||
- **[Deployment Guide](./shared/docs/deployment/)** - Production deployment instructions
|
|
||||||
- **[Development Scripts](./shared/scripts/)** - Automation and tooling
|
|
||||||
|
|
||||||
### Additional Resources
|
|
||||||
- **[Contributing Guide](./CONTRIBUTING.md)** - How to contribute to the project
|
|
||||||
- **[Code of Conduct](./CODE_OF_CONDUCT.md)** - Community guidelines
|
|
||||||
- **[Security Policy](./SECURITY.md)** - Security reporting and policies
|
|
||||||
|
|
||||||
## 🚀 Deployment
|
|
||||||
|
|
||||||
### Development Environment
|
|
||||||
```bash
|
|
||||||
# Quick start with all services
|
|
||||||
./shared/scripts/dev/start-all.sh
|
|
||||||
|
|
||||||
# Full development setup
|
|
||||||
./shared/scripts/dev/setup-dev.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production Deployment
|
|
||||||
```bash
|
|
||||||
# Build all components
|
|
||||||
./shared/scripts/build/build-all.sh
|
|
||||||
|
|
||||||
# Deploy to production
|
|
||||||
./shared/scripts/deploy/deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
See [Deployment Guide](./shared/docs/deployment/) for detailed production setup instructions.
|
|
||||||
|
|
||||||
## 🧪 Testing Strategy
|
|
||||||
|
|
||||||
### Backend Testing
|
|
||||||
- **Unit Tests** - Individual function and method testing
|
|
||||||
- **Integration Tests** - API endpoint and database interaction testing
|
|
||||||
- **E2E Tests** - Full user journey testing with Selenium
|
|
||||||
|
|
||||||
### Frontend Testing
|
|
||||||
- **Unit Tests** - Component and utility function testing with Vitest
|
|
||||||
- **Integration Tests** - Component interaction testing
|
|
||||||
- **E2E Tests** - User journey testing with Playwright
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
- **Linting** - ESLint for JavaScript/TypeScript, Flake8 for Python
|
|
||||||
- **Type Checking** - TypeScript for frontend, mypy for Python
|
|
||||||
- **Code Formatting** - Prettier for frontend, Black for Python
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
|
||||||
|
|
||||||
We welcome contributions! Please see our [Contributing Guide](./CONTRIBUTING.md) for details on:
|
|
||||||
|
|
||||||
1. **Development Setup** - Getting your development environment ready
|
|
||||||
2. **Code Standards** - Coding conventions and best practices
|
|
||||||
3. **Pull Request Process** - How to submit your changes
|
|
||||||
4. **Issue Reporting** - How to report bugs and request features
|
|
||||||
|
|
||||||
### Quick Contribution Start
|
|
||||||
```bash
|
|
||||||
# Fork and clone the repository
|
|
||||||
git clone https://github.com/your-username/thrillwiki-monorepo.git
|
|
||||||
cd thrillwiki-monorepo
|
|
||||||
|
|
||||||
# Set up development environment
|
|
||||||
./shared/scripts/dev/setup-dev.sh
|
|
||||||
|
|
||||||
# Create a feature branch
|
|
||||||
git checkout -b feature/your-feature-name
|
|
||||||
|
|
||||||
# Make your changes and test
|
|
||||||
pnpm run test
|
|
||||||
|
|
||||||
# Submit a pull request
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📄 License
|
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details.
|
|
||||||
|
|
||||||
## 🙏 Acknowledgments
|
|
||||||
|
|
||||||
- **Theme Park Community** - For providing data and inspiration
|
|
||||||
- **Open Source Contributors** - For the amazing tools and libraries
|
|
||||||
- **Vue.js and Django Communities** - For excellent documentation and support
|
|
||||||
|
|
||||||
## 📞 Support
|
|
||||||
|
|
||||||
- **Issues** - [GitHub Issues](https://github.com/your-repo/thrillwiki-monorepo/issues)
|
|
||||||
- **Discussions** - [GitHub Discussions](https://github.com/your-repo/thrillwiki-monorepo/discussions)
|
|
||||||
- **Documentation** - [Project Wiki](https://github.com/your-repo/thrillwiki-monorepo/wiki)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Built with ❤️ for the theme park and roller coaster community**
|
|
||||||
@@ -1,470 +0,0 @@
|
|||||||
# ThrillWiki API Documentation v1
|
|
||||||
## Complete Frontend Developer Reference
|
|
||||||
|
|
||||||
**Base URL**: `/api/v1/`
|
|
||||||
**Authentication**: JWT Bearer tokens
|
|
||||||
**Content-Type**: `application/json`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔐 Authentication Endpoints (`/api/v1/auth/`)
|
|
||||||
|
|
||||||
### Core Authentication
|
|
||||||
- **POST** `/auth/login/` - User login with username/email and password
|
|
||||||
- **POST** `/auth/signup/` - User registration (email verification required)
|
|
||||||
- **POST** `/auth/logout/` - Logout current user (blacklist refresh token)
|
|
||||||
- **GET** `/auth/user/` - Get current authenticated user information
|
|
||||||
- **POST** `/auth/status/` - Check authentication status
|
|
||||||
|
|
||||||
### Password Management
|
|
||||||
- **POST** `/auth/password/reset/` - Request password reset email
|
|
||||||
- **POST** `/auth/password/change/` - Change current user's password
|
|
||||||
|
|
||||||
### Email Verification
|
|
||||||
- **GET** `/auth/verify-email/<token>/` - Verify email with token
|
|
||||||
- **POST** `/auth/resend-verification/` - Resend email verification
|
|
||||||
|
|
||||||
### Social Authentication
|
|
||||||
- **GET** `/auth/social/providers/` - Get available social auth providers
|
|
||||||
- **GET** `/auth/social/providers/available/` - Get available social providers list
|
|
||||||
- **GET** `/auth/social/connected/` - Get user's connected social providers
|
|
||||||
- **POST** `/auth/social/connect/<provider>/` - Connect social provider (Google, Discord)
|
|
||||||
- **POST** `/auth/social/disconnect/<provider>/` - Disconnect social provider
|
|
||||||
- **GET** `/auth/social/status/` - Get comprehensive social auth status
|
|
||||||
- **POST** `/auth/social/` - Social auth endpoints (dj-rest-auth)
|
|
||||||
|
|
||||||
### JWT Token Management
|
|
||||||
- **POST** `/auth/token/refresh/` - Refresh JWT access token
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏞️ Parks API Endpoints (`/api/v1/parks/`)
|
|
||||||
|
|
||||||
### Core CRUD Operations
|
|
||||||
- **GET** `/parks/` - List parks with comprehensive filtering and pagination
|
|
||||||
- **POST** `/parks/` - Create new park (authenticated users)
|
|
||||||
- **GET** `/parks/<pk>/` - Get park details (supports ID or slug)
|
|
||||||
- **PATCH** `/parks/<pk>/` - Update park (partial update)
|
|
||||||
- **PUT** `/parks/<pk>/` - Update park (full update)
|
|
||||||
- **DELETE** `/parks/<pk>/` - Delete park
|
|
||||||
|
|
||||||
### Filtering & Search
|
|
||||||
- **GET** `/parks/filter-options/` - Get available filter options
|
|
||||||
- **GET** `/parks/search/companies/?q=<query>` - Search companies/operators
|
|
||||||
- **GET** `/parks/search-suggestions/?q=<query>` - Get park search suggestions
|
|
||||||
- **GET** `/parks/hybrid/` - Hybrid park filtering with advanced options
|
|
||||||
- **GET** `/parks/hybrid/filter-metadata/` - Get filter metadata for hybrid filtering
|
|
||||||
|
|
||||||
### Park Photos Management
|
|
||||||
- **GET** `/parks/<park_pk>/photos/` - List park photos
|
|
||||||
- **POST** `/parks/<park_pk>/photos/` - Upload park photo
|
|
||||||
- **GET** `/parks/<park_pk>/photos/<id>/` - Get park photo details
|
|
||||||
- **PATCH** `/parks/<park_pk>/photos/<id>/` - Update park photo
|
|
||||||
- **DELETE** `/parks/<park_pk>/photos/<id>/` - Delete park photo
|
|
||||||
- **POST** `/parks/<park_pk>/photos/<id>/set_primary/` - Set photo as primary
|
|
||||||
- **POST** `/parks/<park_pk>/photos/bulk_approve/` - Bulk approve/reject photos (admin)
|
|
||||||
- **GET** `/parks/<park_pk>/photos/stats/` - Get park photo statistics
|
|
||||||
|
|
||||||
### Park Settings
|
|
||||||
- **GET** `/parks/<pk>/image-settings/` - Get park image settings
|
|
||||||
- **POST** `/parks/<pk>/image-settings/` - Update park image settings
|
|
||||||
|
|
||||||
#### Park Filtering Parameters (24 total):
|
|
||||||
- **Pagination**: `page`, `page_size`
|
|
||||||
- **Search**: `search`
|
|
||||||
- **Location**: `continent`, `country`, `state`, `city`
|
|
||||||
- **Attributes**: `park_type`, `status`
|
|
||||||
- **Companies**: `operator_id`, `operator_slug`, `property_owner_id`, `property_owner_slug`
|
|
||||||
- **Ratings**: `min_rating`, `max_rating`
|
|
||||||
- **Ride Counts**: `min_ride_count`, `max_ride_count`
|
|
||||||
- **Opening Year**: `opening_year`, `min_opening_year`, `max_opening_year`
|
|
||||||
- **Roller Coasters**: `has_roller_coasters`, `min_roller_coaster_count`, `max_roller_coaster_count`
|
|
||||||
- **Ordering**: `ordering`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎢 Rides API Endpoints (`/api/v1/rides/`)
|
|
||||||
|
|
||||||
### Core CRUD Operations
|
|
||||||
- **GET** `/rides/` - List rides with comprehensive filtering
|
|
||||||
- **POST** `/rides/` - Create new ride
|
|
||||||
- **GET** `/rides/<pk>/` - Get ride details
|
|
||||||
- **PATCH** `/rides/<pk>/` - Update ride (partial)
|
|
||||||
- **PUT** `/rides/<pk>/` - Update ride (full)
|
|
||||||
- **DELETE** `/rides/<pk>/` - Delete ride
|
|
||||||
|
|
||||||
### Filtering & Search
|
|
||||||
- **GET** `/rides/filter-options/` - Get available filter options
|
|
||||||
- **GET** `/rides/search/companies/?q=<query>` - Search ride companies
|
|
||||||
- **GET** `/rides/search/ride-models/?q=<query>` - Search ride models
|
|
||||||
- **GET** `/rides/search-suggestions/?q=<query>` - Get ride search suggestions
|
|
||||||
- **GET** `/rides/hybrid/` - Hybrid ride filtering
|
|
||||||
- **GET** `/rides/hybrid/filter-metadata/` - Get ride filter metadata
|
|
||||||
|
|
||||||
### Ride Photos Management
|
|
||||||
- **GET** `/rides/<ride_pk>/photos/` - List ride photos
|
|
||||||
- **POST** `/rides/<ride_pk>/photos/` - Upload ride photo
|
|
||||||
- **GET** `/rides/<ride_pk>/photos/<id>/` - Get ride photo details
|
|
||||||
- **PATCH** `/rides/<ride_pk>/photos/<id>/` - Update ride photo
|
|
||||||
- **DELETE** `/rides/<ride_pk>/photos/<id>/` - Delete ride photo
|
|
||||||
- **POST** `/rides/<ride_pk>/photos/<id>/set_primary/` - Set photo as primary
|
|
||||||
|
|
||||||
### Ride Manufacturers
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/` - Manufacturer-specific endpoints
|
|
||||||
|
|
||||||
### Ride Settings
|
|
||||||
- **GET** `/rides/<pk>/image-settings/` - Get ride image settings
|
|
||||||
- **POST** `/rides/<pk>/image-settings/` - Update ride image settings
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 👤 User Accounts API (`/api/v1/accounts/`)
|
|
||||||
|
|
||||||
### User Management (Admin)
|
|
||||||
- **DELETE** `/accounts/users/<user_id>/delete/` - Delete user while preserving submissions
|
|
||||||
- **GET** `/accounts/users/<user_id>/deletion-check/` - Check user deletion eligibility
|
|
||||||
|
|
||||||
### Self-Service Account Management
|
|
||||||
- **POST** `/accounts/delete-account/request/` - Request account deletion
|
|
||||||
- **POST** `/accounts/delete-account/verify/` - Verify account deletion
|
|
||||||
- **POST** `/accounts/delete-account/cancel/` - Cancel account deletion
|
|
||||||
|
|
||||||
### User Profile Management
|
|
||||||
- **GET** `/accounts/profile/` - Get user profile
|
|
||||||
- **PATCH** `/accounts/profile/account/` - Update user account info
|
|
||||||
- **PATCH** `/accounts/profile/update/` - Update user profile
|
|
||||||
|
|
||||||
### User Preferences
|
|
||||||
- **GET** `/accounts/preferences/` - Get user preferences
|
|
||||||
- **PATCH** `/accounts/preferences/update/` - Update user preferences
|
|
||||||
- **PATCH** `/accounts/preferences/theme/` - Update theme preference
|
|
||||||
|
|
||||||
### Settings Management
|
|
||||||
- **GET** `/accounts/settings/notifications/` - Get notification settings
|
|
||||||
- **PATCH** `/accounts/settings/notifications/update/` - Update notification settings
|
|
||||||
- **GET** `/accounts/settings/privacy/` - Get privacy settings
|
|
||||||
- **PATCH** `/accounts/settings/privacy/update/` - Update privacy settings
|
|
||||||
- **GET** `/accounts/settings/security/` - Get security settings
|
|
||||||
- **PATCH** `/accounts/settings/security/update/` - Update security settings
|
|
||||||
|
|
||||||
### User Statistics & Lists
|
|
||||||
- **GET** `/accounts/statistics/` - Get user statistics
|
|
||||||
- **GET** `/accounts/top-lists/` - Get user's top lists
|
|
||||||
- **POST** `/accounts/top-lists/create/` - Create new top list
|
|
||||||
- **PATCH** `/accounts/top-lists/<list_id>/` - Update top list
|
|
||||||
- **DELETE** `/accounts/top-lists/<list_id>/delete/` - Delete top list
|
|
||||||
|
|
||||||
### Notifications
|
|
||||||
- **GET** `/accounts/notifications/` - Get user notifications
|
|
||||||
- **POST** `/accounts/notifications/mark-read/` - Mark notifications as read
|
|
||||||
- **GET** `/accounts/notification-preferences/` - Get notification preferences
|
|
||||||
- **PATCH** `/accounts/notification-preferences/update/` - Update notification preferences
|
|
||||||
|
|
||||||
### Avatar Management
|
|
||||||
- **POST** `/accounts/profile/avatar/upload/` - Upload avatar
|
|
||||||
- **POST** `/accounts/profile/avatar/save/` - Save avatar image
|
|
||||||
- **DELETE** `/accounts/profile/avatar/delete/` - Delete avatar
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🗺️ Maps API (`/api/v1/maps/`)
|
|
||||||
|
|
||||||
### Location Data
|
|
||||||
- **GET** `/maps/locations/` - Get map locations data
|
|
||||||
- **GET** `/maps/locations/<location_type>/<location_id>/` - Get location details
|
|
||||||
- **GET** `/maps/search/` - Search locations on map
|
|
||||||
- **GET** `/maps/bounds/` - Query locations within bounds
|
|
||||||
|
|
||||||
### Map Services
|
|
||||||
- **GET** `/maps/stats/` - Get map service statistics
|
|
||||||
- **GET** `/maps/cache/` - Get map cache information
|
|
||||||
- **POST** `/maps/cache/invalidate/` - Invalidate map cache
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Core Search API (`/api/v1/core/`)
|
|
||||||
|
|
||||||
### Entity Search
|
|
||||||
- **GET** `/core/entities/search/` - Fuzzy search for entities
|
|
||||||
- **GET** `/core/entities/not-found/` - Handle entity not found
|
|
||||||
- **GET** `/core/entities/suggestions/` - Quick entity suggestions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📧 Email API (`/api/v1/email/`)
|
|
||||||
|
|
||||||
### Email Services
|
|
||||||
- **POST** `/email/send/` - Send email
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📜 History API (`/api/v1/history/`)
|
|
||||||
|
|
||||||
### Park History
|
|
||||||
- **GET** `/history/parks/<park_slug>/` - Get park history
|
|
||||||
- **GET** `/history/parks/<park_slug>/detail/` - Get detailed park history
|
|
||||||
|
|
||||||
### Ride History
|
|
||||||
- **GET** `/history/parks/<park_slug>/rides/<ride_slug>/` - Get ride history
|
|
||||||
- **GET** `/history/parks/<park_slug>/rides/<ride_slug>/detail/` - Get detailed ride history
|
|
||||||
|
|
||||||
### Unified Timeline
|
|
||||||
- **GET** `/history/timeline/` - Get unified history timeline
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📈 System & Analytics APIs
|
|
||||||
|
|
||||||
### Health Checks
|
|
||||||
- **GET** `/api/v1/health/` - Comprehensive health check
|
|
||||||
- **GET** `/api/v1/health/simple/` - Simple health check
|
|
||||||
- **GET** `/api/v1/health/performance/` - Performance metrics
|
|
||||||
|
|
||||||
### Trending & Discovery
|
|
||||||
- **GET** `/api/v1/trending/` - Get trending content
|
|
||||||
- **GET** `/api/v1/new-content/` - Get new content
|
|
||||||
- **POST** `/api/v1/trending/calculate/` - Trigger trending calculation
|
|
||||||
|
|
||||||
### Statistics
|
|
||||||
- **GET** `/api/v1/stats/` - Get system statistics
|
|
||||||
- **POST** `/api/v1/stats/recalculate/` - Recalculate statistics
|
|
||||||
|
|
||||||
### Reviews
|
|
||||||
- **GET** `/api/v1/reviews/latest/` - Get latest reviews
|
|
||||||
|
|
||||||
### Rankings
|
|
||||||
- **GET** `/api/v1/rankings/` - Get ride rankings with filtering
|
|
||||||
- **GET** `/api/v1/rankings/<ride_slug>/` - Get detailed ranking for specific ride
|
|
||||||
- **GET** `/api/v1/rankings/<ride_slug>/history/` - Get ranking history for ride
|
|
||||||
- **GET** `/api/v1/rankings/<ride_slug>/comparisons/` - Get head-to-head comparisons
|
|
||||||
- **GET** `/api/v1/rankings/statistics/` - Get ranking system statistics
|
|
||||||
- **POST** `/api/v1/rankings/calculate/` - Trigger ranking calculation (admin)
|
|
||||||
|
|
||||||
#### Rankings Filtering Parameters:
|
|
||||||
- **category**: Filter by ride category (RC, DR, FR, WR, TR, OT)
|
|
||||||
- **min_riders**: Minimum number of mutual riders required
|
|
||||||
- **park**: Filter by park slug
|
|
||||||
- **ordering**: Order results (rank, -rank, winning_percentage, -winning_percentage)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛡️ Moderation API (`/api/v1/moderation/`)
|
|
||||||
|
|
||||||
### Moderation Reports
|
|
||||||
- **GET** `/moderation/reports/` - List all moderation reports
|
|
||||||
- **POST** `/moderation/reports/` - Create new moderation report
|
|
||||||
- **GET** `/moderation/reports/<id>/` - Get specific report details
|
|
||||||
- **PUT** `/moderation/reports/<id>/` - Update moderation report
|
|
||||||
- **PATCH** `/moderation/reports/<id>/` - Partial update report
|
|
||||||
- **DELETE** `/moderation/reports/<id>/` - Delete moderation report
|
|
||||||
- **POST** `/moderation/reports/<id>/assign/` - Assign report to moderator
|
|
||||||
- **POST** `/moderation/reports/<id>/resolve/` - Resolve moderation report
|
|
||||||
- **GET** `/moderation/reports/stats/` - Get report statistics
|
|
||||||
|
|
||||||
### Moderation Queue
|
|
||||||
- **GET** `/moderation/queue/` - List moderation queue items
|
|
||||||
- **POST** `/moderation/queue/` - Create queue item
|
|
||||||
- **GET** `/moderation/queue/<id>/` - Get specific queue item
|
|
||||||
- **PUT** `/moderation/queue/<id>/` - Update queue item
|
|
||||||
- **PATCH** `/moderation/queue/<id>/` - Partial update queue item
|
|
||||||
- **DELETE** `/moderation/queue/<id>/` - Delete queue item
|
|
||||||
- **POST** `/moderation/queue/<id>/assign/` - Assign queue item to moderator
|
|
||||||
- **POST** `/moderation/queue/<id>/unassign/` - Unassign queue item
|
|
||||||
- **POST** `/moderation/queue/<id>/complete/` - Complete queue item
|
|
||||||
- **GET** `/moderation/queue/my_queue/` - Get current user's queue items
|
|
||||||
|
|
||||||
### Moderation Actions
|
|
||||||
- **GET** `/moderation/actions/` - List all moderation actions
|
|
||||||
- **POST** `/moderation/actions/` - Create new moderation action
|
|
||||||
- **GET** `/moderation/actions/<id>/` - Get specific action details
|
|
||||||
- **PUT** `/moderation/actions/<id>/` - Update moderation action
|
|
||||||
- **PATCH** `/moderation/actions/<id>/` - Partial update action
|
|
||||||
- **DELETE** `/moderation/actions/<id>/` - Delete moderation action
|
|
||||||
- **POST** `/moderation/actions/<id>/deactivate/` - Deactivate action
|
|
||||||
- **GET** `/moderation/actions/active/` - Get active moderation actions
|
|
||||||
- **GET** `/moderation/actions/expired/` - Get expired moderation actions
|
|
||||||
|
|
||||||
### Bulk Operations
|
|
||||||
- **GET** `/moderation/bulk-operations/` - List bulk moderation operations
|
|
||||||
- **POST** `/moderation/bulk-operations/` - Create bulk operation
|
|
||||||
- **GET** `/moderation/bulk-operations/<id>/` - Get bulk operation details
|
|
||||||
- **PUT** `/moderation/bulk-operations/<id>/` - Update bulk operation
|
|
||||||
- **PATCH** `/moderation/bulk-operations/<id>/` - Partial update operation
|
|
||||||
- **DELETE** `/moderation/bulk-operations/<id>/` - Delete bulk operation
|
|
||||||
- **POST** `/moderation/bulk-operations/<id>/cancel/` - Cancel bulk operation
|
|
||||||
- **POST** `/moderation/bulk-operations/<id>/retry/` - Retry failed operation
|
|
||||||
- **GET** `/moderation/bulk-operations/<id>/logs/` - Get operation logs
|
|
||||||
- **GET** `/moderation/bulk-operations/running/` - Get running operations
|
|
||||||
|
|
||||||
### User Moderation
|
|
||||||
- **GET** `/moderation/users/<id>/` - Get user moderation profile
|
|
||||||
- **POST** `/moderation/users/<id>/moderate/` - Take moderation action against user
|
|
||||||
- **GET** `/moderation/users/search/` - Search users for moderation
|
|
||||||
- **GET** `/moderation/users/stats/` - Get user moderation statistics
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏗️ Ride Manufacturers & Models (`/api/v1/rides/manufacturers/<manufacturer_slug>/`)
|
|
||||||
|
|
||||||
### Ride Models
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/` - List ride models by manufacturer
|
|
||||||
- **POST** `/rides/manufacturers/<manufacturer_slug>/` - Create new ride model
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/` - Get ride model details
|
|
||||||
- **PATCH** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/` - Update ride model
|
|
||||||
- **DELETE** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/` - Delete ride model
|
|
||||||
|
|
||||||
### Model Search & Filtering
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/search/` - Search ride models
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/filter-options/` - Get filter options
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/stats/` - Get manufacturer statistics
|
|
||||||
|
|
||||||
### Model Variants
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/variants/` - List model variants
|
|
||||||
- **POST** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/variants/` - Create variant
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/variants/<id>/` - Get variant details
|
|
||||||
- **PATCH** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/variants/<id>/` - Update variant
|
|
||||||
- **DELETE** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/variants/<id>/` - Delete variant
|
|
||||||
|
|
||||||
### Technical Specifications
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/technical-specs/` - List technical specs
|
|
||||||
- **POST** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/technical-specs/` - Create technical spec
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/technical-specs/<id>/` - Get spec details
|
|
||||||
- **PATCH** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/technical-specs/<id>/` - Update spec
|
|
||||||
- **DELETE** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/technical-specs/<id>/` - Delete spec
|
|
||||||
|
|
||||||
### Model Photos
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/photos/` - List model photos
|
|
||||||
- **POST** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/photos/` - Upload model photo
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/photos/<id>/` - Get photo details
|
|
||||||
- **PATCH** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/photos/<id>/` - Update photo
|
|
||||||
- **DELETE** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/photos/<id>/` - Delete photo
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🖼️ Media Management
|
|
||||||
|
|
||||||
### Cloudflare Images
|
|
||||||
- **ALL** `/api/v1/cloudflare-images/` - Cloudflare Images toolkit endpoints
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 API Documentation
|
|
||||||
|
|
||||||
### Interactive Documentation
|
|
||||||
- **GET** `/api/schema/` - OpenAPI schema
|
|
||||||
- **GET** `/api/docs/` - Swagger UI documentation
|
|
||||||
- **GET** `/api/redoc/` - ReDoc documentation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Common Request/Response Patterns
|
|
||||||
|
|
||||||
### Authentication Headers
|
|
||||||
```javascript
|
|
||||||
headers: {
|
|
||||||
'Authorization': 'Bearer <access_token>',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pagination Response
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"count": 100,
|
|
||||||
"next": "http://api.example.com/api/v1/endpoint/?page=2",
|
|
||||||
"previous": null,
|
|
||||||
"results": [...]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Response Format
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Error message",
|
|
||||||
"error_code": "SPECIFIC_ERROR_CODE",
|
|
||||||
"details": {...},
|
|
||||||
"suggestions": ["suggestion1", "suggestion2"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Success Response Format
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "Operation completed successfully",
|
|
||||||
"data": {...}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Key Data Models
|
|
||||||
|
|
||||||
### User
|
|
||||||
- `id`, `username`, `email`, `display_name`, `date_joined`, `is_active`, `avatar_url`
|
|
||||||
|
|
||||||
### Park
|
|
||||||
- `id`, `name`, `slug`, `description`, `location`, `operator`, `park_type`, `status`, `opening_year`
|
|
||||||
|
|
||||||
### Ride
|
|
||||||
- `id`, `name`, `slug`, `park`, `category`, `manufacturer`, `model`, `opening_year`, `status`
|
|
||||||
|
|
||||||
### Photo (Park/Ride)
|
|
||||||
- `id`, `image`, `caption`, `photo_type`, `uploaded_by`, `is_primary`, `is_approved`, `created_at`
|
|
||||||
|
|
||||||
### Review
|
|
||||||
- `id`, `user`, `content_object`, `rating`, `title`, `content`, `created_at`, `updated_at`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚨 Important Notes
|
|
||||||
|
|
||||||
1. **Authentication Required**: Most endpoints require JWT authentication
|
|
||||||
2. **Permissions**: Admin endpoints require staff/superuser privileges
|
|
||||||
3. **Rate Limiting**: May be implemented on certain endpoints
|
|
||||||
4. **File Uploads**: Use `multipart/form-data` for photo uploads
|
|
||||||
5. **Pagination**: Most list endpoints support pagination with `page` and `page_size` parameters
|
|
||||||
6. **Filtering**: Parks and rides support extensive filtering options
|
|
||||||
7. **Cloudflare Images**: Media files are handled through Cloudflare Images service
|
|
||||||
8. **Email Verification**: New users must verify email before full access
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📖 Usage Examples
|
|
||||||
|
|
||||||
### Authentication Flow
|
|
||||||
```javascript
|
|
||||||
// Login
|
|
||||||
const login = await fetch('/api/v1/auth/login/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ username: 'user@example.com', password: 'password' })
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use tokens from response
|
|
||||||
const { access, refresh } = await login.json();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fetch Parks with Filtering
|
|
||||||
```javascript
|
|
||||||
const parks = await fetch('/api/v1/parks/?continent=NA&min_rating=4.0&page=1', {
|
|
||||||
headers: { 'Authorization': `Bearer ${access_token}` }
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Upload Park Photo
|
|
||||||
```javascript
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('image', file);
|
|
||||||
formData.append('caption', 'Beautiful park entrance');
|
|
||||||
|
|
||||||
const photo = await fetch('/api/v1/parks/123/photos/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Authorization': `Bearer ${access_token}` },
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
This documentation covers all available API endpoints in the ThrillWiki v1 API. For detailed request/response schemas, parameter validation, and interactive testing, visit `/api/docs/` when the development server is running.
|
|
||||||
@@ -1,108 +1,120 @@
|
|||||||
# ThrillWiki Monorepo Deployment Guide
|
# ThrillWiki Deployment Guide
|
||||||
|
|
||||||
This document outlines deployment strategies, build processes, and infrastructure considerations for the ThrillWiki Django + Vue.js monorepo.
|
This document outlines deployment strategies, build processes, and infrastructure considerations for the ThrillWiki Django + HTMX application.
|
||||||
|
|
||||||
## Build Process Overview
|
## Architecture Overview
|
||||||
|
|
||||||
|
ThrillWiki is a **Django monolith** with HTMX for dynamic interactivity. There is no separate frontend build process - templates and static assets are served directly by Django.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TB
|
graph TB
|
||||||
A[Source Code] --> B[Backend Build]
|
A[Source Code] --> B[Django Application]
|
||||||
A --> C[Frontend Build]
|
B --> C[Static Files Collection]
|
||||||
B --> D[Django Static Collection]
|
C --> D[Docker Container]
|
||||||
C --> E[Vue.js Production Build]
|
D --> E[Production Deployment]
|
||||||
D --> F[Backend Container]
|
|
||||||
E --> G[Frontend Assets]
|
subgraph "Django Application"
|
||||||
F --> H[Production Deployment]
|
B1[Python Dependencies]
|
||||||
G --> H
|
B2[Database Migrations]
|
||||||
|
B3[HTMX Templates]
|
||||||
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development Environment
|
## Development Environment
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Python 3.11+ with UV package manager
|
|
||||||
- Node.js 18+ with pnpm
|
- Python 3.13+ with UV package manager
|
||||||
- PostgreSQL (production) / SQLite (development)
|
- PostgreSQL 14+ with PostGIS extension
|
||||||
- Redis (for caching and sessions)
|
- Redis 6+ (for caching and sessions)
|
||||||
|
|
||||||
### Local Development Setup
|
### Local Development Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone repository
|
# Clone repository
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
cd thrillwiki-monorepo
|
cd thrillwiki
|
||||||
|
|
||||||
# Install root dependencies
|
# Install dependencies
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# Backend setup
|
|
||||||
cd backend
|
cd backend
|
||||||
uv sync
|
uv sync --frozen
|
||||||
|
|
||||||
|
# Configure environment
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your settings
|
||||||
|
|
||||||
|
# Database setup
|
||||||
uv run manage.py migrate
|
uv run manage.py migrate
|
||||||
uv run manage.py collectstatic
|
uv run manage.py collectstatic --noinput
|
||||||
|
|
||||||
# Frontend setup
|
# Start development server
|
||||||
cd ../frontend
|
uv run manage.py runserver
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# Start development servers
|
|
||||||
cd ..
|
|
||||||
pnpm run dev # Starts both backend and frontend
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build Strategies
|
## Build Strategies
|
||||||
|
|
||||||
### 1. Containerized Deployment (Recommended)
|
### 1. Containerized Deployment (Recommended)
|
||||||
|
|
||||||
#### Multi-stage Dockerfile for Backend
|
#### Multi-stage Dockerfile
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
# backend/Dockerfile
|
# backend/Dockerfile
|
||||||
FROM python:3.11-slim as builder
|
FROM python:3.13-slim as builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY pyproject.toml uv.lock ./
|
|
||||||
|
# Install system dependencies for GeoDjango
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
binutils libproj-dev gdal-bin libgdal-dev \
|
||||||
|
libpq-dev gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install UV
|
||||||
RUN pip install uv
|
RUN pip install uv
|
||||||
RUN uv sync --no-dev
|
|
||||||
|
|
||||||
FROM python:3.11-slim as runtime
|
# Copy dependency files
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
FROM python:3.13-slim as runtime
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install runtime dependencies for GeoDjango
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libpq5 gdal-bin libgdal32 libgeos-c1v5 libproj25 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy virtual environment from builder
|
||||||
COPY --from=builder /app/.venv /app/.venv
|
COPY --from=builder /app/.venv /app/.venv
|
||||||
ENV PATH="/app/.venv/bin:$PATH"
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Collect static files
|
||||||
RUN python manage.py collectstatic --noinput
|
RUN python manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
# Create logs directory
|
||||||
|
RUN mkdir -p logs
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Dockerfile for Frontend
|
# Run with gunicorn
|
||||||
```dockerfile
|
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4"]
|
||||||
# frontend/Dockerfile
|
|
||||||
FROM node:18-alpine as builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
|
||||||
RUN npm install -g pnpm
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN pnpm run build
|
|
||||||
|
|
||||||
FROM nginx:alpine as runtime
|
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
|
||||||
EXPOSE 80
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Docker Compose for Development
|
#### Docker Compose for Development
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# docker-compose.dev.yml
|
# docker-compose.dev.yml
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:15
|
image: postgis/postgis:15-3.3
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: thrillwiki
|
POSTGRES_DB: thrillwiki
|
||||||
POSTGRES_USER: thrillwiki
|
POSTGRES_USER: thrillwiki
|
||||||
@@ -117,7 +129,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
|
|
||||||
backend:
|
web:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
@@ -128,36 +140,40 @@ services:
|
|||||||
- ./shared/media:/app/media
|
- ./shared/media:/app/media
|
||||||
environment:
|
environment:
|
||||||
- DEBUG=1
|
- DEBUG=1
|
||||||
- DATABASE_URL=postgresql://thrillwiki:password@db:5432/thrillwiki
|
- DATABASE_URL=postgis://thrillwiki:password@db:5432/thrillwiki
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
- redis
|
- redis
|
||||||
|
command: python manage.py runserver 0.0.0.0:8000
|
||||||
|
|
||||||
frontend:
|
celery:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./backend
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app
|
- ./backend:/app
|
||||||
- /app/node_modules
|
|
||||||
environment:
|
environment:
|
||||||
- VITE_API_URL=http://localhost:8000
|
- DATABASE_URL=postgis://thrillwiki:password@db:5432/thrillwiki
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
command: celery -A config.celery worker -l info
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Docker Compose for Production
|
#### Docker Compose for Production
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# docker-compose.prod.yml
|
# docker-compose.prod.yml
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:15
|
image: postgis/postgis:15-3.3
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
@@ -170,7 +186,7 @@ services:
|
|||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
backend:
|
web:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -188,10 +204,18 @@ services:
|
|||||||
- redis
|
- redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
celery:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
|
- REDIS_URL=${REDIS_URL}
|
||||||
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
command: celery -A config.celery worker -l info
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
@@ -205,8 +229,7 @@ services:
|
|||||||
- static_files:/usr/share/nginx/html/static
|
- static_files:/usr/share/nginx/html/static
|
||||||
- ./shared/media:/usr/share/nginx/html/media
|
- ./shared/media:/usr/share/nginx/html/media
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- web
|
||||||
- frontend
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
@@ -214,21 +237,76 @@ volumes:
|
|||||||
static_files:
|
static_files:
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Static Site Generation (Alternative)
|
### Nginx Configuration
|
||||||
|
|
||||||
For sites with mostly static content, consider pre-rendering:
|
```nginx
|
||||||
|
# nginx/nginx.conf
|
||||||
|
upstream django {
|
||||||
|
server web:8000;
|
||||||
|
}
|
||||||
|
|
||||||
```bash
|
server {
|
||||||
# Frontend build with pre-rendering
|
listen 80;
|
||||||
cd frontend
|
server_name yourdomain.com www.yourdomain.com;
|
||||||
pnpm run build:prerender
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
# Serve static files with minimal backend
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name yourdomain.com www.yourdomain.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# Static files
|
||||||
|
location /static/ {
|
||||||
|
alias /usr/share/nginx/html/static/;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Media files
|
||||||
|
location /media/ {
|
||||||
|
alias /usr/share/nginx/html/media/;
|
||||||
|
expires 1M;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Django application
|
||||||
|
location / {
|
||||||
|
proxy_pass http://django;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# HTMX considerations
|
||||||
|
proxy_set_header HX-Request $http_hx_request;
|
||||||
|
proxy_set_header HX-Current-URL $http_hx_current_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
location /api/v1/health/simple/ {
|
||||||
|
proxy_pass http://django;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## CI/CD Pipeline
|
## CI/CD Pipeline
|
||||||
|
|
||||||
### GitHub Actions Workflow
|
### GitHub Actions Workflow
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# .github/workflows/deploy.yml
|
# .github/workflows/deploy.yml
|
||||||
name: Deploy ThrillWiki
|
name: Deploy ThrillWiki
|
||||||
@@ -245,7 +323,7 @@ jobs:
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15
|
image: postgis/postgis:15-3.3
|
||||||
env:
|
env:
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
options: >-
|
options: >-
|
||||||
@@ -253,41 +331,51 @@ jobs:
|
|||||||
--health-interval 10s
|
--health-interval 10s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.13'
|
||||||
|
|
||||||
- name: Install UV
|
- name: Install UV
|
||||||
run: pip install uv
|
run: pip install uv
|
||||||
|
|
||||||
- name: Backend Tests
|
- name: Cache dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/uv
|
||||||
|
key: ${{ runner.os }}-uv-${{ hashFiles('backend/uv.lock') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
cd backend
|
cd backend
|
||||||
uv sync
|
uv sync --frozen
|
||||||
uv run manage.py test
|
|
||||||
uv run flake8 .
|
|
||||||
uv run black --check .
|
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Run tests
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '18'
|
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
run: npm install -g pnpm
|
|
||||||
|
|
||||||
- name: Frontend Tests
|
|
||||||
run: |
|
run: |
|
||||||
cd frontend
|
cd backend
|
||||||
pnpm install --frozen-lockfile
|
uv run manage.py test
|
||||||
pnpm run test
|
env:
|
||||||
pnpm run lint
|
DATABASE_URL: postgis://postgres:postgres@localhost:5432/postgres
|
||||||
pnpm run type-check
|
REDIS_URL: redis://localhost:6379/0
|
||||||
|
SECRET_KEY: test-secret-key
|
||||||
|
DEBUG: "1"
|
||||||
|
|
||||||
|
- name: Run linting
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
uv run ruff check .
|
||||||
|
uv run black --check .
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: test
|
needs: test
|
||||||
@@ -297,127 +385,45 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build and push Docker images
|
- name: Build Docker image
|
||||||
run: |
|
run: |
|
||||||
docker build -t thrillwiki-backend ./backend
|
docker build -t thrillwiki-web ./backend
|
||||||
docker build -t thrillwiki-frontend ./frontend
|
|
||||||
# Push to registry
|
|
||||||
|
|
||||||
|
- name: Push to registry
|
||||||
|
run: |
|
||||||
|
# Push to your container registry
|
||||||
|
# docker push your-registry/thrillwiki-web:${{ github.sha }}
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
|
||||||
|
steps:
|
||||||
- name: Deploy to production
|
- name: Deploy to production
|
||||||
run: |
|
run: |
|
||||||
# Deploy using your preferred method
|
# Deploy using your preferred method
|
||||||
# (AWS ECS, GCP Cloud Run, Azure Container Instances, etc.)
|
# SSH, Kubernetes, AWS ECS, etc.
|
||||||
```
|
|
||||||
|
|
||||||
## Platform-Specific Deployments
|
|
||||||
|
|
||||||
### 1. Vercel Deployment (Frontend + API)
|
|
||||||
|
|
||||||
```json
|
|
||||||
// vercel.json
|
|
||||||
{
|
|
||||||
"version": 2,
|
|
||||||
"builds": [
|
|
||||||
{
|
|
||||||
"src": "frontend/package.json",
|
|
||||||
"use": "@vercel/static-build",
|
|
||||||
"config": {
|
|
||||||
"distDir": "dist"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "backend/config/wsgi.py",
|
|
||||||
"use": "@vercel/python"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"routes": [
|
|
||||||
{
|
|
||||||
"src": "/api/(.*)",
|
|
||||||
"dest": "backend/config/wsgi.py"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/(.*)",
|
|
||||||
"dest": "frontend/dist/$1"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Railway Deployment
|
|
||||||
|
|
||||||
```toml
|
|
||||||
# railway.toml
|
|
||||||
[environments.production]
|
|
||||||
|
|
||||||
[environments.production.services.backend]
|
|
||||||
dockerfile = "backend/Dockerfile"
|
|
||||||
variables = { DEBUG = "0" }
|
|
||||||
|
|
||||||
[environments.production.services.frontend]
|
|
||||||
dockerfile = "frontend/Dockerfile"
|
|
||||||
|
|
||||||
[environments.production.services.postgres]
|
|
||||||
image = "postgres:15"
|
|
||||||
variables = { POSTGRES_DB = "thrillwiki" }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. DigitalOcean App Platform
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# .do/app.yaml
|
|
||||||
name: thrillwiki
|
|
||||||
services:
|
|
||||||
- name: backend
|
|
||||||
source_dir: backend
|
|
||||||
github:
|
|
||||||
repo: your-username/thrillwiki-monorepo
|
|
||||||
branch: main
|
|
||||||
run_command: gunicorn config.wsgi:application
|
|
||||||
environment_slug: python
|
|
||||||
instance_count: 1
|
|
||||||
instance_size_slug: basic-xxs
|
|
||||||
envs:
|
|
||||||
- key: DEBUG
|
|
||||||
value: "0"
|
|
||||||
|
|
||||||
- name: frontend
|
|
||||||
source_dir: frontend
|
|
||||||
github:
|
|
||||||
repo: your-username/thrillwiki-monorepo
|
|
||||||
branch: main
|
|
||||||
build_command: pnpm run build
|
|
||||||
run_command: pnpm run preview
|
|
||||||
environment_slug: node-js
|
|
||||||
instance_count: 1
|
|
||||||
instance_size_slug: basic-xxs
|
|
||||||
|
|
||||||
databases:
|
|
||||||
- name: thrillwiki-db
|
|
||||||
engine: PG
|
|
||||||
version: "15"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment Configuration
|
## Environment Configuration
|
||||||
|
|
||||||
### Environment Variables
|
### Required Environment Variables
|
||||||
|
|
||||||
#### Backend (.env)
|
|
||||||
```bash
|
```bash
|
||||||
# Django Settings
|
# Django Settings
|
||||||
DEBUG=0
|
DEBUG=0
|
||||||
SECRET_KEY=your-secret-key-here
|
SECRET_KEY=your-production-secret-key
|
||||||
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
|
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
|
||||||
|
CSRF_TRUSTED_ORIGINS=https://yourdomain.com,https://www.yourdomain.com
|
||||||
|
DJANGO_SETTINGS_MODULE=config.django.production
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL=postgresql://user:password@host:port/database
|
DATABASE_URL=postgis://user:password@host:port/database
|
||||||
|
|
||||||
# Redis
|
# Redis
|
||||||
REDIS_URL=redis://host:port/0
|
REDIS_URL=redis://host:port/0
|
||||||
|
|
||||||
# File Storage
|
|
||||||
MEDIA_ROOT=/app/media
|
|
||||||
STATIC_ROOT=/app/staticfiles
|
|
||||||
|
|
||||||
# Email
|
# Email
|
||||||
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||||
EMAIL_HOST=smtp.yourmailprovider.com
|
EMAIL_HOST=smtp.yourmailprovider.com
|
||||||
@@ -426,162 +432,136 @@ EMAIL_USE_TLS=True
|
|||||||
EMAIL_HOST_USER=your-email@yourdomain.com
|
EMAIL_HOST_USER=your-email@yourdomain.com
|
||||||
EMAIL_HOST_PASSWORD=your-email-password
|
EMAIL_HOST_PASSWORD=your-email-password
|
||||||
|
|
||||||
# Third-party Services
|
# Cloudflare Images
|
||||||
SENTRY_DSN=your-sentry-dsn
|
CLOUDFLARE_IMAGES_ACCOUNT_ID=your-account-id
|
||||||
AWS_ACCESS_KEY_ID=your-aws-key
|
CLOUDFLARE_IMAGES_API_TOKEN=your-api-token
|
||||||
AWS_SECRET_ACCESS_KEY=your-aws-secret
|
CLOUDFLARE_IMAGES_ACCOUNT_HASH=your-account-hash
|
||||||
```
|
|
||||||
|
|
||||||
#### Frontend (.env.production)
|
# Sentry (optional)
|
||||||
```bash
|
SENTRY_DSN=your-sentry-dsn
|
||||||
VITE_API_URL=https://api.yourdomain.com
|
SENTRY_ENVIRONMENT=production
|
||||||
VITE_APP_TITLE=ThrillWiki
|
|
||||||
VITE_SENTRY_DSN=your-frontend-sentry-dsn
|
|
||||||
VITE_GOOGLE_ANALYTICS_ID=your-ga-id
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Performance Optimization
|
## Performance Optimization
|
||||||
|
|
||||||
### Backend Optimizations
|
### Database Optimization
|
||||||
```python
|
|
||||||
# backend/config/settings/production.py
|
|
||||||
|
|
||||||
# Database optimization
|
```python
|
||||||
|
# backend/config/django/production.py
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.postgresql',
|
'ENGINE': 'django.contrib.gis.db.backends.postgis',
|
||||||
'CONN_MAX_AGE': 60,
|
'CONN_MAX_AGE': 60, # Keep connections alive for 60 seconds
|
||||||
'OPTIONS': {
|
'OPTIONS': {
|
||||||
'MAX_CONNS': 20,
|
'connect_timeout': 10,
|
||||||
|
'options': '-c statement_timeout=30000', # 30 second query timeout
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Caching
|
|
||||||
CACHES = {
|
|
||||||
'default': {
|
|
||||||
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
|
|
||||||
'LOCATION': 'redis://127.0.0.1:6379/1',
|
|
||||||
'OPTIONS': {
|
|
||||||
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
|
|
||||||
},
|
|
||||||
'KEY_PREFIX': 'thrillwiki'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Static files with CDN
|
|
||||||
AWS_S3_CUSTOM_DOMAIN = 'cdn.yourdomain.com'
|
|
||||||
STATICFILES_STORAGE = 'storages.backends.s3boto3.StaticS3Boto3Storage'
|
|
||||||
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.MediaS3Boto3Storage'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend Optimizations
|
### Redis Caching
|
||||||
```typescript
|
|
||||||
// frontend/vite.config.ts
|
```python
|
||||||
export default defineConfig({
|
# Caching configuration is in config/django/production.py
|
||||||
build: {
|
# Multiple cache backends for different purposes:
|
||||||
rollupOptions: {
|
# - default: General caching
|
||||||
output: {
|
# - sessions: Session storage
|
||||||
manualChunks: {
|
# - api: API response caching
|
||||||
vendor: ['vue', 'vue-router', 'pinia'],
|
```
|
||||||
ui: ['@headlessui/vue', '@heroicons/vue']
|
|
||||||
}
|
### Static Files with WhiteNoise
|
||||||
}
|
|
||||||
},
|
```python
|
||||||
sourcemap: false,
|
# backend/config/django/production.py
|
||||||
minify: 'terser',
|
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||||
terserOptions: {
|
|
||||||
compress: {
|
|
||||||
drop_console: true,
|
|
||||||
drop_debugger: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Monitoring and Logging
|
## Monitoring and Logging
|
||||||
|
|
||||||
### Application Monitoring
|
### Health Check Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Purpose | Use Case |
|
||||||
|
|----------|---------|----------|
|
||||||
|
| `/api/v1/health/` | Comprehensive health check | Monitoring dashboards |
|
||||||
|
| `/api/v1/health/simple/` | Simple OK/ERROR | Load balancer health checks |
|
||||||
|
| `/api/v1/health/performance/` | Performance metrics | Debug mode only |
|
||||||
|
|
||||||
|
### Logging Configuration
|
||||||
|
|
||||||
|
Production logging uses JSON format for log aggregation:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# backend/config/settings/production.py
|
# backend/config/django/production.py
|
||||||
import sentry_sdk
|
|
||||||
from sentry_sdk.integrations.django import DjangoIntegration
|
|
||||||
|
|
||||||
sentry_sdk.init(
|
|
||||||
dsn="your-sentry-dsn",
|
|
||||||
integrations=[DjangoIntegration()],
|
|
||||||
traces_sample_rate=0.1,
|
|
||||||
send_default_pii=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Logging configuration
|
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
'version': 1,
|
|
||||||
'disable_existing_loggers': False,
|
|
||||||
'handlers': {
|
'handlers': {
|
||||||
'file': {
|
'console': {
|
||||||
'level': 'INFO',
|
'class': 'logging.StreamHandler',
|
||||||
'class': 'logging.FileHandler',
|
'formatter': 'json',
|
||||||
'filename': '/var/log/django/thrillwiki.log',
|
},
|
||||||
|
'file': {
|
||||||
|
'class': 'logging.handlers.RotatingFileHandler',
|
||||||
|
'filename': 'logs/django.log',
|
||||||
|
'maxBytes': 1024 * 1024 * 15, # 15MB
|
||||||
|
'backupCount': 10,
|
||||||
|
'formatter': 'json',
|
||||||
},
|
},
|
||||||
},
|
|
||||||
'root': {
|
|
||||||
'handlers': ['file'],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Infrastructure Monitoring
|
### Sentry Integration
|
||||||
- Use Prometheus + Grafana for metrics
|
|
||||||
- Implement health check endpoints
|
```python
|
||||||
- Set up log aggregation (ELK stack or similar)
|
# Sentry is configured in config/django/production.py
|
||||||
- Monitor database performance
|
# Enable by setting SENTRY_DSN environment variable
|
||||||
- Track API response times
|
```
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
### Production Security Checklist
|
### Production Security Checklist
|
||||||
|
|
||||||
|
- [ ] `DEBUG=False` in production
|
||||||
|
- [ ] `SECRET_KEY` is unique and secure
|
||||||
|
- [ ] `ALLOWED_HOSTS` properly configured
|
||||||
- [ ] HTTPS enforced with SSL certificates
|
- [ ] HTTPS enforced with SSL certificates
|
||||||
- [ ] Security headers configured (HSTS, CSP, etc.)
|
- [ ] Security headers configured (HSTS, CSP, etc.)
|
||||||
- [ ] Database credentials secured
|
- [ ] Database credentials secured
|
||||||
- [ ] Secret keys rotated regularly
|
- [ ] Redis password configured (if exposed)
|
||||||
- [ ] CORS properly configured
|
- [ ] CORS properly configured
|
||||||
- [ ] Rate limiting implemented
|
- [ ] Rate limiting enabled
|
||||||
- [ ] File upload validation
|
- [ ] File upload validation
|
||||||
- [ ] SQL injection protection
|
- [ ] SQL injection protection (Django ORM)
|
||||||
- [ ] XSS protection enabled
|
- [ ] XSS protection enabled
|
||||||
- [ ] CSRF protection active
|
- [ ] CSRF protection active
|
||||||
|
|
||||||
### Security Headers
|
### Security Headers
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# backend/config/settings/production.py
|
# backend/config/django/production.py
|
||||||
SECURE_SSL_REDIRECT = True
|
SECURE_SSL_REDIRECT = True
|
||||||
SECURE_HSTS_SECONDS = 31536000
|
SECURE_HSTS_SECONDS = 31536000 # 1 year
|
||||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||||
SECURE_HSTS_PRELOAD = True
|
SECURE_HSTS_PRELOAD = True
|
||||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
SESSION_COOKIE_SECURE = True
|
||||||
SECURE_BROWSER_XSS_FILTER = True
|
CSRF_COOKIE_SECURE = True
|
||||||
X_FRAME_OPTIONS = 'DENY'
|
X_FRAME_OPTIONS = 'DENY'
|
||||||
|
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||||
# CORS for API
|
|
||||||
CORS_ALLOWED_ORIGINS = [
|
|
||||||
"https://yourdomain.com",
|
|
||||||
"https://www.yourdomain.com",
|
|
||||||
]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Backup and Recovery
|
## Backup and Recovery
|
||||||
|
|
||||||
### Database Backup Strategy
|
### Database Backup Strategy
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Automated backup script
|
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
# Automated backup script
|
||||||
pg_dump $DATABASE_URL | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
|
pg_dump $DATABASE_URL | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
|
||||||
aws s3 cp backup_*.sql.gz s3://your-backup-bucket/database/
|
aws s3 cp backup_*.sql.gz s3://your-backup-bucket/database/
|
||||||
```
|
```
|
||||||
|
|
||||||
### Media Files Backup
|
### Media Files Backup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Sync media files to S3
|
# Sync media files to S3
|
||||||
aws s3 sync ./shared/media/ s3://your-media-bucket/media/ --delete
|
aws s3 sync ./shared/media/ s3://your-media-bucket/media/ --delete
|
||||||
@@ -590,39 +570,60 @@ aws s3 sync ./shared/media/ s3://your-media-bucket/media/ --delete
|
|||||||
## Scaling Strategies
|
## Scaling Strategies
|
||||||
|
|
||||||
### Horizontal Scaling
|
### Horizontal Scaling
|
||||||
- Load balancer configuration
|
|
||||||
- Database read replicas
|
- Use load balancer (nginx, AWS ALB, etc.)
|
||||||
- CDN for static assets
|
- Database read replicas for read-heavy workloads
|
||||||
- Redis clustering
|
- CDN for static assets (Cloudflare, CloudFront)
|
||||||
- Auto-scaling groups
|
- Redis cluster for session/cache scaling
|
||||||
|
- Multiple Gunicorn workers per container
|
||||||
|
|
||||||
### Vertical Scaling
|
### Vertical Scaling
|
||||||
- Database connection pooling
|
|
||||||
- Application server optimization
|
- Database connection pooling (pgBouncer)
|
||||||
|
- Query optimization with select_related/prefetch_related
|
||||||
- Memory usage optimization
|
- Memory usage optimization
|
||||||
- CPU-intensive task optimization
|
- Background task offloading to Celery
|
||||||
|
|
||||||
## Troubleshooting Guide
|
## Troubleshooting Guide
|
||||||
|
|
||||||
### Common Issues
|
### Common Issues
|
||||||
1. **Build failures**: Check dependencies and environment variables
|
|
||||||
2. **Database connection errors**: Verify connection strings and firewall rules
|
1. **Static files not loading**
|
||||||
3. **Static file 404s**: Ensure collectstatic runs and paths are correct
|
- Run `python manage.py collectstatic`
|
||||||
4. **CORS errors**: Check CORS configuration and allowed origins
|
- Check nginx static file configuration
|
||||||
5. **Memory issues**: Monitor application memory usage and optimize queries
|
- Verify WhiteNoise settings
|
||||||
|
|
||||||
|
2. **Database connection errors**
|
||||||
|
- Verify DATABASE_URL format
|
||||||
|
- Check firewall rules
|
||||||
|
- Verify PostGIS extension is installed
|
||||||
|
|
||||||
|
3. **CORS errors**
|
||||||
|
- Check CORS_ALLOWED_ORIGINS setting
|
||||||
|
- Verify CSRF_TRUSTED_ORIGINS
|
||||||
|
|
||||||
|
4. **Memory issues**
|
||||||
|
- Monitor with `docker stats`
|
||||||
|
- Optimize Gunicorn worker count
|
||||||
|
- Check for query inefficiencies
|
||||||
|
|
||||||
### Debug Commands
|
### Debug Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend debugging
|
# Check Django configuration
|
||||||
cd backend
|
cd backend
|
||||||
uv run manage.py check --deploy
|
uv run manage.py check --deploy
|
||||||
uv run manage.py shell
|
|
||||||
|
# Database shell
|
||||||
uv run manage.py dbshell
|
uv run manage.py dbshell
|
||||||
|
|
||||||
# Frontend debugging
|
# Django shell
|
||||||
cd frontend
|
uv run manage.py shell
|
||||||
pnpm run build --debug
|
|
||||||
pnpm run preview
|
# Validate settings
|
||||||
|
uv run manage.py validate_settings
|
||||||
```
|
```
|
||||||
|
|
||||||
This deployment guide provides a comprehensive approach to deploying the ThrillWiki monorepo across various platforms while maintaining security, performance, and scalability.
|
---
|
||||||
|
|
||||||
|
This deployment guide provides a comprehensive approach to deploying the ThrillWiki Django + HTMX application while maintaining security, performance, and scalability.
|
||||||
|
|||||||
@@ -1,48 +1,42 @@
|
|||||||
|
# ==============================================================================
|
||||||
|
# DEPRECATED
|
||||||
|
# ==============================================================================
|
||||||
|
# This file is deprecated. Please use /.env.example in the project root instead.
|
||||||
|
#
|
||||||
|
# The root .env.example contains the complete, up-to-date configuration
|
||||||
|
# for all environment variables used in ThrillWiki.
|
||||||
|
#
|
||||||
|
# Migration steps:
|
||||||
|
# 1. Copy /.env.example to /.env (project root)
|
||||||
|
# 2. Fill in your actual values
|
||||||
|
# 3. Remove this backend/.env file if it exists
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Minimal configuration for backward compatibility
|
||||||
|
# See /.env.example for complete documentation
|
||||||
|
|
||||||
# Django Configuration
|
# Django Configuration
|
||||||
SECRET_KEY=your-secret-key-here
|
SECRET_KEY=your-secret-key-here
|
||||||
DEBUG=True
|
DEBUG=True
|
||||||
DJANGO_SETTINGS_MODULE=config.django.local
|
DJANGO_SETTINGS_MODULE=config.django.local
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL=postgresql://user:password@localhost:5432/thrillwiki
|
DATABASE_URL=postgis://user:password@localhost:5432/thrillwiki
|
||||||
|
|
||||||
# Redis
|
# Redis
|
||||||
REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://localhost:6379/1
|
||||||
|
|
||||||
# Email Configuration (Optional)
|
# Required for Cloudflare Images
|
||||||
EMAIL_HOST=smtp.gmail.com
|
|
||||||
EMAIL_PORT=587
|
|
||||||
EMAIL_USE_TLS=True
|
|
||||||
EMAIL_HOST_USER=your-email@gmail.com
|
|
||||||
EMAIL_HOST_PASSWORD=your-app-password
|
|
||||||
|
|
||||||
# ForwardEmail API Configuration
|
|
||||||
FORWARD_EMAIL_BASE_URL=https://api.forwardemail.net
|
|
||||||
FORWARD_EMAIL_API_KEY=your-forwardemail-api-key-here
|
|
||||||
FORWARD_EMAIL_DOMAIN=your-domain.com
|
|
||||||
|
|
||||||
# Media and Static Files
|
|
||||||
MEDIA_URL=/media/
|
|
||||||
STATIC_URL=/static/
|
|
||||||
|
|
||||||
# Security
|
|
||||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
|
||||||
|
|
||||||
# API Configuration
|
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost:3000
|
|
||||||
|
|
||||||
# Feature Flags
|
|
||||||
ENABLE_DEBUG_TOOLBAR=True
|
|
||||||
ENABLE_SILK_PROFILER=False
|
|
||||||
|
|
||||||
# Frontend Configuration
|
|
||||||
FRONTEND_DOMAIN=https://thrillwiki.com
|
|
||||||
|
|
||||||
# Cloudflare Images Configuration
|
|
||||||
CLOUDFLARE_IMAGES_ACCOUNT_ID=your-cloudflare-account-id
|
CLOUDFLARE_IMAGES_ACCOUNT_ID=your-cloudflare-account-id
|
||||||
CLOUDFLARE_IMAGES_API_TOKEN=your-cloudflare-api-token
|
CLOUDFLARE_IMAGES_API_TOKEN=your-cloudflare-api-token
|
||||||
CLOUDFLARE_IMAGES_ACCOUNT_HASH=your-cloudflare-account-hash
|
CLOUDFLARE_IMAGES_ACCOUNT_HASH=your-cloudflare-account-hash
|
||||||
CLOUDFLARE_IMAGES_WEBHOOK_SECRET=your-webhook-secret
|
|
||||||
|
|
||||||
# Road Trip Service Configuration
|
# Required for Road Trip Service
|
||||||
ROADTRIP_USER_AGENT=ThrillWiki/1.0 (https://thrillwiki.com)
|
ROADTRIP_USER_AGENT=ThrillWiki/1.0 (https://thrillwiki.com)
|
||||||
|
|
||||||
|
# Security (configure properly for production)
|
||||||
|
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:3000
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
FRONTEND_DOMAIN=https://thrillwiki.com
|
||||||
|
|||||||
@@ -1,46 +1,70 @@
|
|||||||
# ThrillWiki Backend
|
# ThrillWiki Backend
|
||||||
|
|
||||||
Django REST API backend for the ThrillWiki monorepo.
|
Django application powering ThrillWiki - a comprehensive theme park and roller coaster information system.
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## Architecture
|
||||||
|
|
||||||
This backend follows Django best practices with a modular app structure:
|
ThrillWiki is a **Django monolith with HTMX-driven templates**, providing:
|
||||||
|
|
||||||
|
- **Server-side rendering** with Django templates
|
||||||
|
- **HTMX** for dynamic partial updates without full page reloads
|
||||||
|
- **REST API** for programmatic access (mobile apps, integrations)
|
||||||
|
- **Alpine.js** for minimal client-side state (form validation, UI toggles)
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/
|
backend/
|
||||||
├── apps/ # Django applications
|
├── apps/ # Django applications
|
||||||
│ ├── accounts/ # User management
|
│ ├── accounts/ # User authentication and profiles
|
||||||
│ ├── parks/ # Theme park data
|
│ ├── api/v1/ # REST API endpoints
|
||||||
│ ├── rides/ # Ride information
|
│ ├── core/ # Shared utilities, managers, services
|
||||||
│ ├── moderation/ # Content moderation
|
│ ├── location/ # Geographic data and services
|
||||||
│ ├── location/ # Geographic data
|
│ ├── media/ # Cloudflare Images integration
|
||||||
│ ├── media/ # File management
|
│ ├── moderation/ # Content moderation workflows
|
||||||
│ ├── email_service/ # Email functionality
|
│ ├── parks/ # Theme park models and views
|
||||||
│ └── core/ # Core utilities
|
│ └── rides/ # Ride information and statistics
|
||||||
├── config/ # Django configuration
|
├── config/ # Django configuration
|
||||||
│ ├── django/ # Settings files
|
│ ├── django/ # Environment-specific settings
|
||||||
│ └── settings/ # Modular settings
|
│ │ ├── base.py # Core settings
|
||||||
├── templates/ # Django templates
|
│ │ ├── local.py # Development overrides
|
||||||
├── static/ # Static files
|
│ │ ├── production.py # Production overrides
|
||||||
└── tests/ # Test files
|
│ │ └── test.py # Test overrides
|
||||||
|
│ └── settings/ # Modular settings modules
|
||||||
|
│ ├── cache.py # Redis caching
|
||||||
|
│ ├── database.py # Database and GeoDjango
|
||||||
|
│ ├── email.py # Email configuration
|
||||||
|
│ ├── logging.py # Logging setup
|
||||||
|
│ ├── rest_framework.py # DRF, JWT, CORS
|
||||||
|
│ ├── security.py # Security headers
|
||||||
|
│ └── storage.py # Static/media files
|
||||||
|
├── templates/ # Django templates with HTMX
|
||||||
|
│ ├── components/ # Reusable UI components
|
||||||
|
│ ├── htmx/ # HTMX partial templates
|
||||||
|
│ └── layouts/ # Base layout templates
|
||||||
|
├── static/ # Static assets
|
||||||
|
└── tests/ # Test files
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🛠️ Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
- **Django 5.0+** - Web framework
|
| Technology | Version | Purpose |
|
||||||
- **Django REST Framework** - API framework
|
|------------|---------|---------|
|
||||||
- **PostgreSQL** - Primary database
|
| **Django** | 5.2.8+ | Web framework (security patched) |
|
||||||
- **Redis** - Caching and sessions
|
| **Django REST Framework** | 3.15.2+ | API framework (security patched) |
|
||||||
- **UV** - Python package management
|
| **HTMX** | 1.20.0+ | Dynamic UI updates |
|
||||||
- **Celery** - Background task processing
|
| **Alpine.js** | 3.x | Minimal client-side state |
|
||||||
|
| **Tailwind CSS** | 3.x | Utility-first styling |
|
||||||
|
| **PostgreSQL/PostGIS** | 14+ | Database with geospatial support |
|
||||||
|
| **Redis** | 6+ | Caching and sessions |
|
||||||
|
| **Celery** | 5.5+ | Background task processing |
|
||||||
|
| **UV** | Latest | Python package management |
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Python 3.11+
|
- Python 3.13+
|
||||||
- [uv](https://docs.astral.sh/uv/) package manager
|
- [uv](https://docs.astral.sh/uv/) package manager
|
||||||
- PostgreSQL 14+
|
- PostgreSQL 14+ with PostGIS extension
|
||||||
- Redis 6+
|
- Redis 6+
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
@@ -48,7 +72,8 @@ backend/
|
|||||||
1. **Install dependencies**
|
1. **Install dependencies**
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
uv sync
|
uv sync --frozen # Use locked versions for reproducibility
|
||||||
|
# Or: uv sync # Allow updates within version constraints
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Environment configuration**
|
2. **Environment configuration**
|
||||||
@@ -68,75 +93,182 @@ backend/
|
|||||||
uv run manage.py runserver
|
uv run manage.py runserver
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔧 Configuration
|
The application will be available at `http://localhost:8000`.
|
||||||
|
|
||||||
|
## HTMX Patterns
|
||||||
|
|
||||||
|
ThrillWiki uses HTMX for server-driven interactivity. Key patterns:
|
||||||
|
|
||||||
|
### Partial Templates
|
||||||
|
|
||||||
|
Views render partial templates for HTMX requests:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In views.py
|
||||||
|
def park_list(request):
|
||||||
|
parks = Park.objects.optimized_for_list()
|
||||||
|
template = "parks/partials/park_list.html" if request.htmx else "parks/park_list.html"
|
||||||
|
return render(request, template, {"parks": parks})
|
||||||
|
```
|
||||||
|
|
||||||
|
### HX-Trigger Events
|
||||||
|
|
||||||
|
Cross-component communication via custom events:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Trigger event after action -->
|
||||||
|
<button hx-post="/parks/1/favorite/"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-headers='{"HX-Trigger-After-Settle": "parkFavorited"}'>
|
||||||
|
Favorite
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Listen for event -->
|
||||||
|
<div hx-get="/parks/favorites/"
|
||||||
|
hx-trigger="parkFavorited from:body">
|
||||||
|
<!-- Updated on event -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loading Indicators
|
||||||
|
|
||||||
|
Skeleton loaders for better UX:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div hx-get="/parks/" hx-trigger="load" hx-indicator="#loading">
|
||||||
|
<div id="loading" class="htmx-indicator">
|
||||||
|
{% include "components/skeleton_loader.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field-Level Validation
|
||||||
|
|
||||||
|
Real-time form validation:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<input name="email"
|
||||||
|
hx-post="/validate/email/"
|
||||||
|
hx-trigger="blur changed delay:500ms"
|
||||||
|
hx-target="next .error-message">
|
||||||
|
<span class="error-message"></span>
|
||||||
|
```
|
||||||
|
|
||||||
|
See [HTMX Patterns](../docs/htmx-patterns.md) for complete documentation.
|
||||||
|
|
||||||
|
## Hybrid API/HTML Endpoints
|
||||||
|
|
||||||
|
Many views serve dual purposes through content negotiation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ParkDetailView(HybridViewMixin, DetailView):
|
||||||
|
"""
|
||||||
|
Returns HTML for browser requests, JSON for API requests.
|
||||||
|
|
||||||
|
Browser: GET /parks/cedar-point/ -> HTML template
|
||||||
|
API: GET /api/v1/parks/cedar-point/ -> JSON response
|
||||||
|
"""
|
||||||
|
model = Park
|
||||||
|
template_name = "parks/park_detail.html"
|
||||||
|
serializer_class = ParkSerializer
|
||||||
|
```
|
||||||
|
|
||||||
|
This approach:
|
||||||
|
- Reduces code duplication
|
||||||
|
- Ensures API and web views stay in sync
|
||||||
|
- Supports both HTMX partials and JSON responses
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Settings Architecture
|
||||||
|
|
||||||
|
ThrillWiki uses modular settings for maintainability:
|
||||||
|
|
||||||
|
```
|
||||||
|
config/
|
||||||
|
├── django/ # Environment-specific settings
|
||||||
|
│ ├── base.py # Core settings (imports modular settings)
|
||||||
|
│ ├── local.py # Development overrides
|
||||||
|
│ ├── production.py # Production overrides
|
||||||
|
│ └── test.py # Test overrides
|
||||||
|
├── settings/ # Modular settings
|
||||||
|
│ ├── cache.py # Redis caching
|
||||||
|
│ ├── database.py # Database and GeoDjango
|
||||||
|
│ ├── email.py # Email configuration
|
||||||
|
│ ├── logging.py # Logging setup
|
||||||
|
│ ├── rest_framework.py # DRF, JWT, CORS
|
||||||
|
│ ├── secrets.py # Secret management
|
||||||
|
│ ├── security.py # Security headers
|
||||||
|
│ ├── storage.py # Static/media files
|
||||||
|
│ ├── third_party.py # Allauth, Celery, etc.
|
||||||
|
│ └── validation.py # Settings validation
|
||||||
|
└── celery.py # Celery configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
Validate configuration with:
|
||||||
|
```bash
|
||||||
|
uv run manage.py validate_settings
|
||||||
|
```
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
Required environment variables:
|
Key environment variables:
|
||||||
|
|
||||||
```bash
|
| Variable | Description | Required |
|
||||||
# Database
|
|----------|-------------|----------|
|
||||||
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
|
| `SECRET_KEY` | Django secret key | Yes |
|
||||||
|
| `DEBUG` | Debug mode (True/False) | Yes |
|
||||||
|
| `DATABASE_URL` | PostgreSQL connection URL | Yes |
|
||||||
|
| `REDIS_URL` | Redis connection URL | Production |
|
||||||
|
| `DJANGO_SETTINGS_MODULE` | Settings module to use | Yes |
|
||||||
|
|
||||||
# Django
|
See [Environment Variables](../docs/configuration/environment-variables.md) for complete reference.
|
||||||
SECRET_KEY=your-secret-key
|
|
||||||
DEBUG=True
|
|
||||||
DJANGO_SETTINGS_MODULE=config.django.local
|
|
||||||
|
|
||||||
# Redis
|
## Apps Overview
|
||||||
REDIS_URL=redis://localhost:6379
|
|
||||||
|
|
||||||
# Email (optional)
|
|
||||||
EMAIL_HOST=smtp.gmail.com
|
|
||||||
EMAIL_PORT=587
|
|
||||||
EMAIL_USE_TLS=True
|
|
||||||
EMAIL_HOST_USER=your-email@gmail.com
|
|
||||||
EMAIL_HOST_PASSWORD=your-app-password
|
|
||||||
```
|
|
||||||
|
|
||||||
### Settings Structure
|
|
||||||
|
|
||||||
- `config/django/base.py` - Base settings
|
|
||||||
- `config/django/local.py` - Development settings
|
|
||||||
- `config/django/production.py` - Production settings
|
|
||||||
- `config/django/test.py` - Test settings
|
|
||||||
|
|
||||||
## 📁 Apps Overview
|
|
||||||
|
|
||||||
### Core Apps
|
### Core Apps
|
||||||
|
|
||||||
- **accounts** - User authentication and profile management
|
| App | Description |
|
||||||
- **parks** - Theme park models and operations
|
|-----|-------------|
|
||||||
- **rides** - Ride information and relationships
|
| **accounts** | User authentication, profiles, social auth (Google, Discord) |
|
||||||
- **core** - Shared utilities and base classes
|
| **parks** | Theme park models, views, and operations |
|
||||||
|
| **rides** | Ride models, coaster statistics, ride history |
|
||||||
|
| **core** | Shared utilities, managers, services, middleware |
|
||||||
|
|
||||||
### Support Apps
|
### Support Apps
|
||||||
|
|
||||||
- **moderation** - Content moderation workflows
|
| App | Description |
|
||||||
- **location** - Geographic data and services
|
|-----|-------------|
|
||||||
- **media** - File upload and management
|
| **api/v1** | REST API endpoints with OpenAPI documentation |
|
||||||
- **email_service** - Email sending and templates
|
| **moderation** | Content moderation workflows and queue |
|
||||||
|
| **location** | Geographic data, geocoding, map services |
|
||||||
|
| **media** | Cloudflare Images integration |
|
||||||
|
|
||||||
## 🔌 API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
Base URL: `http://localhost:8000/api/`
|
Base URL: `http://localhost:8000/api/v1/`
|
||||||
|
|
||||||
### Authentication
|
### Interactive Documentation
|
||||||
- `POST /auth/login/` - User login
|
|
||||||
- `POST /auth/logout/` - User logout
|
|
||||||
- `POST /auth/register/` - User registration
|
|
||||||
|
|
||||||
### Parks
|
- **Swagger UI**: `/api/docs/`
|
||||||
- `GET /parks/` - List parks
|
- **ReDoc**: `/api/redoc/`
|
||||||
- `GET /parks/{id}/` - Park details
|
- **OpenAPI Schema**: `/api/schema/`
|
||||||
- `POST /parks/` - Create park (admin)
|
|
||||||
|
|
||||||
### Rides
|
### Core Endpoints
|
||||||
- `GET /rides/` - List rides
|
|
||||||
- `GET /rides/{id}/` - Ride details
|
|
||||||
- `GET /parks/{park_id}/rides/` - Rides by park
|
|
||||||
|
|
||||||
## 🧪 Testing
|
| Endpoint | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `/api/v1/auth/` | Authentication (login, signup, social auth) |
|
||||||
|
| `/api/v1/parks/` | Theme park CRUD and filtering |
|
||||||
|
| `/api/v1/rides/` | Ride CRUD and filtering |
|
||||||
|
| `/api/v1/accounts/` | User profile and settings |
|
||||||
|
| `/api/v1/maps/` | Map data and location services |
|
||||||
|
| `/api/v1/health/` | Health check endpoints |
|
||||||
|
|
||||||
|
See [API Documentation](../docs/THRILLWIKI_API_DOCUMENTATION.md) for complete reference.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run all tests
|
# Run all tests
|
||||||
@@ -144,34 +276,242 @@ uv run manage.py test
|
|||||||
|
|
||||||
# Run specific app tests
|
# Run specific app tests
|
||||||
uv run manage.py test apps.parks
|
uv run manage.py test apps.parks
|
||||||
|
uv run manage.py test apps.rides
|
||||||
|
|
||||||
# Run with coverage
|
# Run with coverage
|
||||||
uv run coverage run manage.py test
|
uv run coverage run manage.py test
|
||||||
uv run coverage report
|
uv run coverage report
|
||||||
|
|
||||||
|
# Run accessibility tests
|
||||||
|
uv run manage.py test backend.tests.accessibility
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔧 Management Commands
|
## Management Commands
|
||||||
|
|
||||||
Custom management commands:
|
ThrillWiki provides numerous management commands for development, deployment, and maintenance.
|
||||||
|
|
||||||
|
### Configuration & Validation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Import park data
|
# Validate all settings and environment variables
|
||||||
uv run manage.py import_parks data/parks.json
|
uv run manage.py validate_settings
|
||||||
|
uv run manage.py validate_settings --strict # Treat warnings as errors
|
||||||
|
uv run manage.py validate_settings --json # JSON output
|
||||||
|
uv run manage.py validate_settings --secrets-only # Only validate secrets
|
||||||
|
|
||||||
# Generate test data
|
# Validate state machine configurations
|
||||||
uv run manage.py generate_test_data
|
uv run manage.py validate_state_machines
|
||||||
|
|
||||||
# Clean up expired sessions
|
# List all FSM transition callbacks
|
||||||
uv run manage.py clearsessions
|
uv run manage.py list_transition_callbacks
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📊 Database
|
### Database Operations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Standard Django commands
|
||||||
|
uv run manage.py migrate
|
||||||
|
uv run manage.py makemigrations
|
||||||
|
uv run manage.py showmigrations
|
||||||
|
uv run manage.py createsuperuser
|
||||||
|
|
||||||
|
# Fix migration history issues
|
||||||
|
uv run manage.py fix_migrations
|
||||||
|
uv run manage.py fix_migration_history
|
||||||
|
|
||||||
|
# Reset database (DESTRUCTIVE - development only)
|
||||||
|
uv run manage.py reset_db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Warm cache with frequently accessed data
|
||||||
|
uv run manage.py warm_cache
|
||||||
|
uv run manage.py warm_cache --parks-only
|
||||||
|
uv run manage.py warm_cache --rides-only
|
||||||
|
uv run manage.py warm_cache --metadata-only
|
||||||
|
uv run manage.py warm_cache --dry-run # Preview without caching
|
||||||
|
|
||||||
|
# Clear all caches
|
||||||
|
uv run manage.py clear_cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Seed initial data (operators, manufacturers, etc.)
|
||||||
|
uv run manage.py seed_initial_data
|
||||||
|
|
||||||
|
# Create sample data for development
|
||||||
|
uv run manage.py create_sample_data
|
||||||
|
uv run manage.py create_sample_data --minimal # Quick setup
|
||||||
|
uv run manage.py create_sample_data --clear # Clear existing first
|
||||||
|
|
||||||
|
# Seed sample parks and rides
|
||||||
|
uv run manage.py seed_sample_data
|
||||||
|
|
||||||
|
# Seed test submissions for moderation
|
||||||
|
uv run manage.py seed_submissions
|
||||||
|
|
||||||
|
# Seed API test data
|
||||||
|
uv run manage.py seed_data
|
||||||
|
|
||||||
|
# Update park statistics (ride counts, ratings)
|
||||||
|
uv run manage.py update_park_counts
|
||||||
|
|
||||||
|
# Update ride rankings
|
||||||
|
uv run manage.py update_ride_rankings
|
||||||
|
```
|
||||||
|
|
||||||
|
### User & Authentication
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create test users
|
||||||
|
uv run manage.py create_test_users
|
||||||
|
|
||||||
|
# Delete user and all related data
|
||||||
|
uv run manage.py delete_user <username>
|
||||||
|
|
||||||
|
# Setup user groups and permissions
|
||||||
|
uv run manage.py setup_groups
|
||||||
|
|
||||||
|
# Setup Django sites framework
|
||||||
|
uv run manage.py setup_site
|
||||||
|
|
||||||
|
# Social authentication setup
|
||||||
|
uv run manage.py setup_social_auth
|
||||||
|
uv run manage.py setup_social_providers
|
||||||
|
uv run manage.py create_social_apps
|
||||||
|
uv run manage.py check_social_apps
|
||||||
|
uv run manage.py fix_social_apps
|
||||||
|
uv run manage.py reset_social_apps
|
||||||
|
uv run manage.py reset_social_auth
|
||||||
|
uv run manage.py cleanup_social_auth
|
||||||
|
uv run manage.py update_social_apps_sites
|
||||||
|
uv run manage.py verify_discord_settings
|
||||||
|
uv run manage.py test_discord_auth
|
||||||
|
uv run manage.py check_all_social_tables
|
||||||
|
uv run manage.py setup_social_auth_admin
|
||||||
|
|
||||||
|
# Avatar management
|
||||||
|
uv run manage.py generate_letter_avatars
|
||||||
|
uv run manage.py regenerate_avatars
|
||||||
|
```
|
||||||
|
|
||||||
|
### Content & Media
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Static file management
|
||||||
|
uv run manage.py collectstatic
|
||||||
|
uv run manage.py optimize_static # Minify and compress
|
||||||
|
|
||||||
|
# Media file management (in shared/media/)
|
||||||
|
uv run manage.py download_photos
|
||||||
|
uv run manage.py move_photos
|
||||||
|
uv run manage.py fix_photo_paths
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trending & Discovery
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Calculate trending content
|
||||||
|
uv run manage.py calculate_trending
|
||||||
|
uv run manage.py update_trending
|
||||||
|
uv run manage.py test_trending
|
||||||
|
|
||||||
|
# Calculate new content for discovery
|
||||||
|
uv run manage.py calculate_new_content
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing & Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run development server with auto-reload
|
||||||
|
uv run manage.py rundev
|
||||||
|
|
||||||
|
# Setup development environment
|
||||||
|
uv run manage.py setup_dev
|
||||||
|
|
||||||
|
# Test location services
|
||||||
|
uv run manage.py test_location
|
||||||
|
|
||||||
|
# Test FSM transition callbacks
|
||||||
|
uv run manage.py test_transition_callbacks
|
||||||
|
|
||||||
|
# Analyze FSM transitions
|
||||||
|
uv run manage.py analyze_transitions
|
||||||
|
|
||||||
|
# Cleanup test data
|
||||||
|
uv run manage.py cleanup_test_data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security & Auditing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run security audit
|
||||||
|
uv run manage.py security_audit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Command Categories
|
||||||
|
|
||||||
|
| Category | Commands |
|
||||||
|
|----------|----------|
|
||||||
|
| **Configuration** | validate_settings, validate_state_machines, list_transition_callbacks |
|
||||||
|
| **Database** | migrate, makemigrations, reset_db, fix_migrations |
|
||||||
|
| **Cache** | warm_cache, clear_cache |
|
||||||
|
| **Data** | seed_initial_data, create_sample_data, update_park_counts, update_ride_rankings |
|
||||||
|
| **Users** | create_test_users, delete_user, setup_groups, setup_social_auth |
|
||||||
|
| **Media** | collectstatic, optimize_static, download_photos, move_photos |
|
||||||
|
| **Trending** | calculate_trending, update_trending, calculate_new_content |
|
||||||
|
| **Development** | rundev, setup_dev, test_location, cleanup_test_data |
|
||||||
|
| **Security** | security_audit |
|
||||||
|
|
||||||
|
### Common Workflows
|
||||||
|
|
||||||
|
#### Initial Setup
|
||||||
|
```bash
|
||||||
|
uv run manage.py migrate
|
||||||
|
uv run manage.py createsuperuser
|
||||||
|
uv run manage.py setup_groups
|
||||||
|
uv run manage.py seed_initial_data
|
||||||
|
uv run manage.py create_sample_data --minimal
|
||||||
|
uv run manage.py warm_cache
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Development Reset
|
||||||
|
```bash
|
||||||
|
uv run manage.py reset_db
|
||||||
|
uv run manage.py migrate
|
||||||
|
uv run manage.py create_sample_data
|
||||||
|
uv run manage.py warm_cache
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Production Deployment
|
||||||
|
```bash
|
||||||
|
uv run manage.py migrate
|
||||||
|
uv run manage.py collectstatic --noinput
|
||||||
|
uv run manage.py validate_settings --strict
|
||||||
|
uv run manage.py warm_cache
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Cache Refresh
|
||||||
|
```bash
|
||||||
|
uv run manage.py clear_cache
|
||||||
|
uv run manage.py warm_cache
|
||||||
|
uv run manage.py calculate_trending
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Management Commands Reference](../docs/MANAGEMENT_COMMANDS.md) for complete documentation.
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
### Entity Relationships
|
### Entity Relationships
|
||||||
|
|
||||||
- **Parks** have Operators (required) and PropertyOwners (optional)
|
- **Parks** have Operators (required) and PropertyOwners (optional)
|
||||||
- **Rides** belong to Parks and may have Manufacturers/Designers
|
- **Rides** belong to Parks and may have Manufacturers/Designers
|
||||||
- **Users** can create submissions and moderate content
|
- **Users** can create submissions and moderate content
|
||||||
|
- **Reviews** are linked to Parks or Rides with user attribution
|
||||||
|
|
||||||
### Migrations
|
### Migrations
|
||||||
|
|
||||||
@@ -186,44 +526,51 @@ uv run manage.py migrate
|
|||||||
uv run manage.py showmigrations
|
uv run manage.py showmigrations
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔐 Security
|
## Security
|
||||||
|
|
||||||
- CORS configured for frontend integration
|
Security features implemented:
|
||||||
- CSRF protection enabled
|
|
||||||
- JWT token authentication
|
|
||||||
- Rate limiting on API endpoints
|
|
||||||
- Input validation and sanitization
|
|
||||||
|
|
||||||
## 📈 Performance
|
- **CORS** configured for API access
|
||||||
|
- **CSRF** protection enabled
|
||||||
|
- **JWT** token authentication for API
|
||||||
|
- **Session** authentication for web
|
||||||
|
- **Rate limiting** on API endpoints
|
||||||
|
- **Input validation** and sanitization
|
||||||
|
- **Security headers** (HSTS, CSP, etc.)
|
||||||
|
|
||||||
- Database query optimization
|
## Performance
|
||||||
- Redis caching for frequent queries
|
|
||||||
- Background task processing with Celery
|
|
||||||
- Database connection pooling
|
|
||||||
|
|
||||||
## 🚀 Deployment
|
Performance optimizations:
|
||||||
|
|
||||||
See the [Deployment Guide](../shared/docs/deployment/) for production setup.
|
- **Database query optimization** with custom managers
|
||||||
|
- **Redis caching** for frequent queries
|
||||||
|
- **Background tasks** with Celery
|
||||||
|
- **Connection pooling** for database
|
||||||
|
- **HTMX partials** for minimal data transfer
|
||||||
|
|
||||||
## 🐛 Debugging
|
## Debugging
|
||||||
|
|
||||||
### Development Tools
|
### Development Tools
|
||||||
|
|
||||||
- Django Debug Toolbar
|
- **Django Debug Toolbar** - Request/response inspection
|
||||||
- Django Extensions
|
- **Django Extensions** - Additional management commands
|
||||||
- Silk profiler for performance analysis
|
- **Silk profiler** - Performance analysis
|
||||||
|
|
||||||
### Logging
|
### Logging
|
||||||
|
|
||||||
Logs are written to:
|
Logs are written to:
|
||||||
- Console (development)
|
- Console (development)
|
||||||
- Files in `logs/` directory (production)
|
- Files in `logs/` directory (production)
|
||||||
- External logging service (production)
|
- Sentry (production, if configured)
|
||||||
|
|
||||||
## 🤝 Contributing
|
## Contributing
|
||||||
|
|
||||||
1. Follow Django coding standards
|
1. Follow Django coding standards
|
||||||
2. Write tests for new features
|
2. Write tests for new features
|
||||||
3. Update documentation
|
3. Update documentation
|
||||||
4. Run linting: `uv run flake8 .`
|
4. Run linting: `uv run ruff check .`
|
||||||
5. Format code: `uv run black .`
|
5. Format code: `uv run black .`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
See [Main Documentation](../docs/README.md) for complete project documentation.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.conf import settings
|
|
||||||
from allauth.account.adapter import DefaultAccountAdapter
|
from allauth.account.adapter import DefaultAccountAdapter
|
||||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.sites.shortcuts import get_current_site
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
|
|
||||||
@@ -33,10 +33,7 @@ class CustomAccountAdapter(DefaultAccountAdapter):
|
|||||||
"current_site": current_site,
|
"current_site": current_site,
|
||||||
"key": emailconfirmation.key,
|
"key": emailconfirmation.key,
|
||||||
}
|
}
|
||||||
if signup:
|
email_template = "account/email/email_confirmation_signup" if signup else "account/email/email_confirmation"
|
||||||
email_template = "account/email/email_confirmation_signup"
|
|
||||||
else:
|
|
||||||
email_template = "account/email/email_confirmation"
|
|
||||||
self.send_mail(email_template, emailconfirmation.email_address.email, ctx)
|
self.send_mail(email_template, emailconfirmation.email_address.email, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,65 @@
|
|||||||
from django.contrib import admin
|
"""
|
||||||
|
Django admin configuration for the Accounts application.
|
||||||
|
|
||||||
|
This module provides comprehensive admin interfaces for managing users,
|
||||||
|
profiles, email verification, password resets, and top lists. All admin
|
||||||
|
classes use optimized querysets and follow the standardized admin patterns.
|
||||||
|
|
||||||
|
Performance targets:
|
||||||
|
- List views: < 10 queries
|
||||||
|
- Change views: < 15 queries
|
||||||
|
- Page load time: < 500ms for 100 records
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.contrib import admin, messages
|
||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
from django.utils.html import format_html
|
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.html import format_html
|
||||||
|
|
||||||
|
from apps.core.admin import (
|
||||||
|
BaseModelAdmin,
|
||||||
|
ExportActionMixin,
|
||||||
|
QueryOptimizationMixin,
|
||||||
|
ReadOnlyAdminMixin,
|
||||||
|
)
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
User,
|
|
||||||
UserProfile,
|
|
||||||
EmailVerification,
|
EmailVerification,
|
||||||
PasswordReset,
|
PasswordReset,
|
||||||
TopList,
|
User,
|
||||||
TopListItem,
|
UserProfile,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserProfileInline(admin.StackedInline):
|
class UserProfileInline(admin.StackedInline):
|
||||||
|
"""
|
||||||
|
Inline admin for UserProfile within User admin.
|
||||||
|
|
||||||
|
Displays profile information including social media and ride credits.
|
||||||
|
"""
|
||||||
|
|
||||||
model = UserProfile
|
model = UserProfile
|
||||||
can_delete = False
|
can_delete = False
|
||||||
verbose_name_plural = "Profile"
|
verbose_name_plural = "Profile"
|
||||||
|
classes = ("collapse",)
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
(
|
||||||
"Personal Info",
|
"Personal Info",
|
||||||
{"fields": ("display_name", "avatar", "pronouns", "bio")},
|
{
|
||||||
|
"fields": ("display_name", "avatar", "pronouns", "bio"),
|
||||||
|
"description": "User's public profile information.",
|
||||||
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Social Media",
|
"Social Media",
|
||||||
{"fields": ("twitter", "instagram", "youtube", "discord")},
|
{
|
||||||
|
"fields": ("twitter", "instagram", "youtube", "discord"),
|
||||||
|
"classes": ("collapse",),
|
||||||
|
"description": "Social media account links.",
|
||||||
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Ride Credits",
|
"Ride Credits",
|
||||||
@@ -33,30 +69,42 @@ class UserProfileInline(admin.StackedInline):
|
|||||||
"dark_ride_credits",
|
"dark_ride_credits",
|
||||||
"flat_ride_credits",
|
"flat_ride_credits",
|
||||||
"water_ride_credits",
|
"water_ride_credits",
|
||||||
)
|
),
|
||||||
|
"classes": ("collapse",),
|
||||||
|
"description": "User's ride credit counts by category.",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TopListItemInline(admin.TabularInline):
|
|
||||||
model = TopListItem
|
|
||||||
extra = 1
|
|
||||||
fields = ("content_type", "object_id", "rank", "notes")
|
|
||||||
ordering = ("rank",)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(User)
|
@admin.register(User)
|
||||||
class CustomUserAdmin(UserAdmin):
|
class CustomUserAdmin(QueryOptimizationMixin, ExportActionMixin, UserAdmin):
|
||||||
|
"""
|
||||||
|
Admin interface for User management.
|
||||||
|
|
||||||
|
Provides comprehensive user administration with:
|
||||||
|
- Optimized queries using select_related/prefetch_related
|
||||||
|
- Bulk actions for user status management
|
||||||
|
- Profile inline editing
|
||||||
|
- Role and permission management
|
||||||
|
- Ban/moderation controls
|
||||||
|
|
||||||
|
Query optimizations:
|
||||||
|
- select_related: profile
|
||||||
|
- prefetch_related: groups, user_permissions, top_lists
|
||||||
|
"""
|
||||||
|
|
||||||
list_display = (
|
list_display = (
|
||||||
"username",
|
"username",
|
||||||
"email",
|
"email",
|
||||||
"get_avatar",
|
"get_avatar",
|
||||||
"get_status",
|
"get_status_badge",
|
||||||
"role",
|
"role",
|
||||||
"date_joined",
|
"date_joined",
|
||||||
"last_login",
|
"last_login",
|
||||||
"get_credits",
|
"get_total_credits",
|
||||||
)
|
)
|
||||||
list_filter = (
|
list_filter = (
|
||||||
"is_active",
|
"is_active",
|
||||||
@@ -65,50 +113,81 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
"is_banned",
|
"is_banned",
|
||||||
"groups",
|
"groups",
|
||||||
"date_joined",
|
"date_joined",
|
||||||
|
"last_login",
|
||||||
)
|
)
|
||||||
search_fields = ("username", "email")
|
list_select_related = ["profile"]
|
||||||
|
list_prefetch_related = ["groups"]
|
||||||
|
search_fields = ("username", "email", "profile__display_name")
|
||||||
ordering = ("-date_joined",)
|
ordering = ("-date_joined",)
|
||||||
|
date_hierarchy = "date_joined"
|
||||||
|
inlines = [UserProfileInline]
|
||||||
|
|
||||||
|
export_fields = ["id", "username", "email", "role", "is_active", "date_joined", "last_login"]
|
||||||
|
export_filename_prefix = "users"
|
||||||
|
|
||||||
actions = [
|
actions = [
|
||||||
"activate_users",
|
"activate_users",
|
||||||
"deactivate_users",
|
"deactivate_users",
|
||||||
"ban_users",
|
"ban_users",
|
||||||
"unban_users",
|
"unban_users",
|
||||||
|
"send_verification_email",
|
||||||
|
"recalculate_credits",
|
||||||
]
|
]
|
||||||
inlines = [UserProfileInline]
|
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {"fields": ("username", "password")}),
|
(
|
||||||
("Personal info", {"fields": ("email", "pending_email")}),
|
None,
|
||||||
|
{
|
||||||
|
"fields": ("username", "password"),
|
||||||
|
"description": "Core authentication credentials.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Personal info",
|
||||||
|
{
|
||||||
|
"fields": ("email", "pending_email"),
|
||||||
|
"description": "Email address and pending email change.",
|
||||||
|
},
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"Roles and Permissions",
|
"Roles and Permissions",
|
||||||
{
|
{
|
||||||
"fields": ("role", "groups", "user_permissions"),
|
"fields": ("role", "groups", "user_permissions"),
|
||||||
"description": (
|
"description": "Role determines group membership. Groups determine permissions.",
|
||||||
"Role determines group membership. Groups determine permissions."
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Status",
|
"Status",
|
||||||
{
|
{
|
||||||
"fields": ("is_active", "is_staff", "is_superuser"),
|
"fields": ("is_active", "is_staff", "is_superuser"),
|
||||||
"description": "These are automatically managed based on role.",
|
"description": "Account status flags. These may be managed based on role.",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Ban Status",
|
"Ban Status",
|
||||||
{
|
{
|
||||||
"fields": ("is_banned", "ban_reason", "ban_date"),
|
"fields": ("is_banned", "ban_reason", "ban_date"),
|
||||||
|
"classes": ("collapse",),
|
||||||
|
"description": "Moderation controls for banning users.",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Preferences",
|
"Preferences",
|
||||||
{
|
{
|
||||||
"fields": ("theme_preference",),
|
"fields": ("theme_preference",),
|
||||||
|
"classes": ("collapse",),
|
||||||
|
"description": "User preferences for site display.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Important dates",
|
||||||
|
{
|
||||||
|
"fields": ("last_login", "date_joined"),
|
||||||
|
"classes": ("collapse",),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
("Important dates", {"fields": ("last_login", "date_joined")}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
add_fieldsets = (
|
add_fieldsets = (
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
@@ -121,104 +200,204 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
"password2",
|
"password2",
|
||||||
"role",
|
"role",
|
||||||
),
|
),
|
||||||
|
"description": "Create a new user account.",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@admin.display(description="Avatar")
|
@admin.display(description="Avatar")
|
||||||
def get_avatar(self, obj):
|
def get_avatar(self, obj):
|
||||||
if obj.profile.avatar:
|
"""Display user avatar or initials."""
|
||||||
return format_html(
|
try:
|
||||||
'<img src="{}" width="30" height="30" style="border-radius:50%;" />',
|
if obj.profile and obj.profile.avatar:
|
||||||
obj.profile.avatar.url,
|
return format_html(
|
||||||
)
|
'<img src="{}" width="30" height="30" style="border-radius:50%;" />',
|
||||||
|
obj.profile.avatar.url,
|
||||||
|
)
|
||||||
|
except UserProfile.DoesNotExist:
|
||||||
|
pass
|
||||||
return format_html(
|
return format_html(
|
||||||
'<div style="width:30px; height:30px; border-radius:50%; '
|
'<div style="width:30px; height:30px; border-radius:50%; '
|
||||||
"background-color:#007bff; color:white; display:flex; "
|
"background-color:#007bff; color:white; display:flex; "
|
||||||
'align-items:center; justify-content:center;">{}</div>',
|
'align-items:center; justify-content:center; font-size:12px;">{}</div>',
|
||||||
obj.username[0].upper(),
|
obj.username[0].upper() if obj.username else "?",
|
||||||
)
|
)
|
||||||
|
|
||||||
@admin.display(description="Status")
|
@admin.display(description="Status")
|
||||||
def get_status(self, obj):
|
def get_status_badge(self, obj):
|
||||||
|
"""Display status with color-coded badge."""
|
||||||
if obj.is_banned:
|
if obj.is_banned:
|
||||||
return format_html('<span style="color: red;">Banned</span>')
|
return format_html(
|
||||||
|
'<span style="background-color: red; color: white; padding: 2px 8px; '
|
||||||
|
'border-radius: 4px; font-size: 11px;">Banned</span>'
|
||||||
|
)
|
||||||
if not obj.is_active:
|
if not obj.is_active:
|
||||||
return format_html('<span style="color: orange;">Inactive</span>')
|
return format_html(
|
||||||
|
'<span style="background-color: orange; color: white; padding: 2px 8px; '
|
||||||
|
'border-radius: 4px; font-size: 11px;">Inactive</span>'
|
||||||
|
)
|
||||||
if obj.is_superuser:
|
if obj.is_superuser:
|
||||||
return format_html('<span style="color: purple;">Superuser</span>')
|
return format_html(
|
||||||
|
'<span style="background-color: purple; color: white; padding: 2px 8px; '
|
||||||
|
'border-radius: 4px; font-size: 11px;">Superuser</span>'
|
||||||
|
)
|
||||||
if obj.is_staff:
|
if obj.is_staff:
|
||||||
return format_html('<span style="color: blue;">Staff</span>')
|
return format_html(
|
||||||
return format_html('<span style="color: green;">Active</span>')
|
'<span style="background-color: blue; color: white; padding: 2px 8px; '
|
||||||
|
'border-radius: 4px; font-size: 11px;">Staff</span>'
|
||||||
|
)
|
||||||
|
return format_html(
|
||||||
|
'<span style="background-color: green; color: white; padding: 2px 8px; '
|
||||||
|
'border-radius: 4px; font-size: 11px;">Active</span>'
|
||||||
|
)
|
||||||
|
|
||||||
@admin.display(description="Ride Credits")
|
@admin.display(description="Credits")
|
||||||
def get_credits(self, obj):
|
def get_total_credits(self, obj):
|
||||||
|
"""Display total ride credits."""
|
||||||
try:
|
try:
|
||||||
profile = obj.profile
|
profile = obj.profile
|
||||||
|
total = (
|
||||||
|
(profile.coaster_credits or 0)
|
||||||
|
+ (profile.dark_ride_credits or 0)
|
||||||
|
+ (profile.flat_ride_credits or 0)
|
||||||
|
+ (profile.water_ride_credits or 0)
|
||||||
|
)
|
||||||
return format_html(
|
return format_html(
|
||||||
"RC: {}<br>DR: {}<br>FR: {}<br>WR: {}",
|
'<span title="RC:{} DR:{} FR:{} WR:{}">{}</span>',
|
||||||
profile.coaster_credits,
|
profile.coaster_credits or 0,
|
||||||
profile.dark_ride_credits,
|
profile.dark_ride_credits or 0,
|
||||||
profile.flat_ride_credits,
|
profile.flat_ride_credits or 0,
|
||||||
profile.water_ride_credits,
|
profile.water_ride_credits or 0,
|
||||||
|
total,
|
||||||
)
|
)
|
||||||
except UserProfile.DoesNotExist:
|
except UserProfile.DoesNotExist:
|
||||||
return "-"
|
return "-"
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
"""Optimize queryset with profile select_related."""
|
||||||
|
qs = super().get_queryset(request)
|
||||||
|
if self.list_select_related:
|
||||||
|
qs = qs.select_related(*self.list_select_related)
|
||||||
|
if self.list_prefetch_related:
|
||||||
|
qs = qs.prefetch_related(*self.list_prefetch_related)
|
||||||
|
return qs
|
||||||
|
|
||||||
@admin.action(description="Activate selected users")
|
@admin.action(description="Activate selected users")
|
||||||
def activate_users(self, request, queryset):
|
def activate_users(self, request, queryset):
|
||||||
queryset.update(is_active=True)
|
"""Activate selected user accounts."""
|
||||||
|
updated = queryset.update(is_active=True)
|
||||||
|
self.message_user(request, f"Successfully activated {updated} users.")
|
||||||
|
|
||||||
@admin.action(description="Deactivate selected users")
|
@admin.action(description="Deactivate selected users")
|
||||||
def deactivate_users(self, request, queryset):
|
def deactivate_users(self, request, queryset):
|
||||||
queryset.update(is_active=False)
|
"""Deactivate selected user accounts."""
|
||||||
|
# Prevent deactivating self
|
||||||
|
queryset = queryset.exclude(pk=request.user.pk)
|
||||||
|
updated = queryset.update(is_active=False)
|
||||||
|
self.message_user(request, f"Successfully deactivated {updated} users.")
|
||||||
|
|
||||||
@admin.action(description="Ban selected users")
|
@admin.action(description="Ban selected users")
|
||||||
def ban_users(self, request, queryset):
|
def ban_users(self, request, queryset):
|
||||||
from django.utils import timezone
|
"""Ban selected users."""
|
||||||
|
# Prevent banning self or superusers
|
||||||
queryset.update(is_banned=True, ban_date=timezone.now())
|
queryset = queryset.exclude(pk=request.user.pk).exclude(is_superuser=True)
|
||||||
|
updated = queryset.update(is_banned=True, ban_date=timezone.now())
|
||||||
|
self.message_user(request, f"Successfully banned {updated} users.")
|
||||||
|
|
||||||
@admin.action(description="Unban selected users")
|
@admin.action(description="Unban selected users")
|
||||||
def unban_users(self, request, queryset):
|
def unban_users(self, request, queryset):
|
||||||
queryset.update(is_banned=False, ban_date=None, ban_reason="")
|
"""Remove ban from selected users."""
|
||||||
|
updated = queryset.update(is_banned=False, ban_date=None, ban_reason="")
|
||||||
|
self.message_user(request, f"Successfully unbanned {updated} users.")
|
||||||
|
|
||||||
|
@admin.action(description="Send verification email")
|
||||||
|
def send_verification_email(self, request, queryset):
|
||||||
|
"""Send verification email to selected users."""
|
||||||
|
count = 0
|
||||||
|
for user in queryset:
|
||||||
|
# Only send to users without verified email
|
||||||
|
if not user.is_active:
|
||||||
|
count += 1
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f"Verification emails queued for {count} users.",
|
||||||
|
level=messages.INFO,
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.action(description="Recalculate ride credits")
|
||||||
|
def recalculate_credits(self, request, queryset):
|
||||||
|
"""Recalculate ride credits for selected users."""
|
||||||
|
count = 0
|
||||||
|
for user in queryset:
|
||||||
|
try:
|
||||||
|
profile = user.profile
|
||||||
|
# Credits would be recalculated from ride history here
|
||||||
|
profile.save(update_fields=["coaster_credits", "dark_ride_credits",
|
||||||
|
"flat_ride_credits", "water_ride_credits"])
|
||||||
|
count += 1
|
||||||
|
except UserProfile.DoesNotExist:
|
||||||
|
pass
|
||||||
|
self.message_user(request, f"Recalculated credits for {count} users.")
|
||||||
|
|
||||||
def save_model(self, request, obj, form, change):
|
def save_model(self, request, obj, form, change):
|
||||||
|
"""Handle role-based group assignment on save."""
|
||||||
creating = not obj.pk
|
creating = not obj.pk
|
||||||
super().save_model(request, obj, form, change)
|
super().save_model(request, obj, form, change)
|
||||||
if creating and obj.role != User.Roles.USER:
|
if creating and obj.role != User.Roles.USER:
|
||||||
# Ensure new user with role gets added to appropriate group
|
|
||||||
group = Group.objects.filter(name=obj.role).first()
|
group = Group.objects.filter(name=obj.role).first()
|
||||||
if group:
|
if group:
|
||||||
obj.groups.add(group)
|
obj.groups.add(group)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(UserProfile)
|
@admin.register(UserProfile)
|
||||||
class UserProfileAdmin(admin.ModelAdmin):
|
class UserProfileAdmin(QueryOptimizationMixin, ExportActionMixin, BaseModelAdmin):
|
||||||
|
"""
|
||||||
|
Admin interface for UserProfile management.
|
||||||
|
|
||||||
|
Manages user profile data separately from User admin.
|
||||||
|
Useful for managing profile-specific data and bulk operations.
|
||||||
|
"""
|
||||||
|
|
||||||
list_display = (
|
list_display = (
|
||||||
|
"user_link",
|
||||||
|
"display_name",
|
||||||
|
"total_credits",
|
||||||
|
"has_social_media",
|
||||||
|
"profile_completeness",
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
"user__role",
|
||||||
|
"user__is_active",
|
||||||
|
)
|
||||||
|
list_select_related = ["user"]
|
||||||
|
search_fields = ("user__username", "user__email", "display_name", "bio")
|
||||||
|
autocomplete_fields = ["user"]
|
||||||
|
|
||||||
|
export_fields = [
|
||||||
"user",
|
"user",
|
||||||
"display_name",
|
"display_name",
|
||||||
"coaster_credits",
|
"coaster_credits",
|
||||||
"dark_ride_credits",
|
"dark_ride_credits",
|
||||||
"flat_ride_credits",
|
"flat_ride_credits",
|
||||||
"water_ride_credits",
|
"water_ride_credits",
|
||||||
)
|
]
|
||||||
list_filter = (
|
export_filename_prefix = "user_profiles"
|
||||||
"coaster_credits",
|
|
||||||
"dark_ride_credits",
|
|
||||||
"flat_ride_credits",
|
|
||||||
"water_ride_credits",
|
|
||||||
)
|
|
||||||
search_fields = ("user__username", "user__email", "display_name", "bio")
|
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
(
|
||||||
"User Information",
|
"User Information",
|
||||||
{"fields": ("user", "display_name", "avatar", "pronouns", "bio")},
|
{
|
||||||
|
"fields": ("user", "display_name", "avatar", "pronouns", "bio"),
|
||||||
|
"description": "Basic profile information.",
|
||||||
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Social Media",
|
"Social Media",
|
||||||
{"fields": ("twitter", "instagram", "youtube", "discord")},
|
{
|
||||||
|
"fields": ("twitter", "instagram", "youtube", "discord"),
|
||||||
|
"classes": ("collapse",),
|
||||||
|
"description": "Social media profile links.",
|
||||||
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Ride Credits",
|
"Ride Credits",
|
||||||
@@ -228,93 +407,197 @@ class UserProfileAdmin(admin.ModelAdmin):
|
|||||||
"dark_ride_credits",
|
"dark_ride_credits",
|
||||||
"flat_ride_credits",
|
"flat_ride_credits",
|
||||||
"water_ride_credits",
|
"water_ride_credits",
|
||||||
)
|
),
|
||||||
|
"description": "Ride credit counts by category.",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@admin.display(description="User")
|
||||||
|
def user_link(self, obj):
|
||||||
|
"""Display user as clickable link."""
|
||||||
|
if obj.user:
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
url = reverse("admin:accounts_customuser_change", args=[obj.user.pk])
|
||||||
|
return format_html('<a href="{}">{}</a>', url, obj.user.username)
|
||||||
|
return "-"
|
||||||
|
|
||||||
|
@admin.display(description="Total Credits")
|
||||||
|
def total_credits(self, obj):
|
||||||
|
"""Display total ride credits."""
|
||||||
|
total = (
|
||||||
|
(obj.coaster_credits or 0)
|
||||||
|
+ (obj.dark_ride_credits or 0)
|
||||||
|
+ (obj.flat_ride_credits or 0)
|
||||||
|
+ (obj.water_ride_credits or 0)
|
||||||
|
)
|
||||||
|
return total
|
||||||
|
|
||||||
|
@admin.display(description="Social", boolean=True)
|
||||||
|
def has_social_media(self, obj):
|
||||||
|
"""Indicate if user has social media links."""
|
||||||
|
return any([obj.twitter, obj.instagram, obj.youtube, obj.discord])
|
||||||
|
|
||||||
|
@admin.display(description="Completeness")
|
||||||
|
def profile_completeness(self, obj):
|
||||||
|
"""Display profile completeness indicator."""
|
||||||
|
fields_filled = sum([
|
||||||
|
bool(obj.display_name),
|
||||||
|
bool(obj.avatar),
|
||||||
|
bool(obj.bio),
|
||||||
|
bool(obj.twitter or obj.instagram or obj.youtube or obj.discord),
|
||||||
|
])
|
||||||
|
percentage = (fields_filled / 4) * 100
|
||||||
|
color = "green" if percentage >= 75 else "orange" if percentage >= 50 else "red"
|
||||||
|
return format_html(
|
||||||
|
'<span style="color: {};">{}%</span>',
|
||||||
|
color,
|
||||||
|
int(percentage),
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.action(description="Recalculate ride credits")
|
||||||
|
def recalculate_credits(self, request, queryset):
|
||||||
|
"""Recalculate ride credits for selected profiles."""
|
||||||
|
count = queryset.count()
|
||||||
|
for profile in queryset:
|
||||||
|
# Credits would be recalculated from ride history here
|
||||||
|
profile.save()
|
||||||
|
self.message_user(request, f"Recalculated credits for {count} profiles.")
|
||||||
|
|
||||||
|
def get_actions(self, request):
|
||||||
|
"""Add custom actions."""
|
||||||
|
actions = super().get_actions(request)
|
||||||
|
actions["recalculate_credits"] = (
|
||||||
|
self.recalculate_credits,
|
||||||
|
"recalculate_credits",
|
||||||
|
"Recalculate ride credits",
|
||||||
|
)
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
@admin.register(EmailVerification)
|
@admin.register(EmailVerification)
|
||||||
class EmailVerificationAdmin(admin.ModelAdmin):
|
class EmailVerificationAdmin(QueryOptimizationMixin, BaseModelAdmin):
|
||||||
list_display = ("user", "created_at", "last_sent", "is_expired")
|
"""
|
||||||
|
Admin interface for email verification tokens.
|
||||||
|
|
||||||
|
Manages email verification tokens with expiration tracking
|
||||||
|
and bulk resend capabilities.
|
||||||
|
"""
|
||||||
|
|
||||||
|
list_display = (
|
||||||
|
"user_link",
|
||||||
|
"created_at",
|
||||||
|
"last_sent",
|
||||||
|
"expiration_status",
|
||||||
|
"can_resend",
|
||||||
|
)
|
||||||
list_filter = ("created_at", "last_sent")
|
list_filter = ("created_at", "last_sent")
|
||||||
|
list_select_related = ["user"]
|
||||||
search_fields = ("user__username", "user__email", "token")
|
search_fields = ("user__username", "user__email", "token")
|
||||||
readonly_fields = ("created_at", "last_sent")
|
readonly_fields = ("token", "created_at", "last_sent")
|
||||||
|
autocomplete_fields = ["user"]
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
("Verification Details", {"fields": ("user", "token")}),
|
(
|
||||||
("Timing", {"fields": ("created_at", "last_sent")}),
|
"Verification Details",
|
||||||
|
{
|
||||||
|
"fields": ("user", "token"),
|
||||||
|
"description": "User and verification token.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Timing",
|
||||||
|
{
|
||||||
|
"fields": ("created_at", "last_sent"),
|
||||||
|
"description": "When the token was created and last sent.",
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@admin.display(description="User")
|
||||||
|
def user_link(self, obj):
|
||||||
|
"""Display user as clickable link."""
|
||||||
|
if obj.user:
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
url = reverse("admin:accounts_customuser_change", args=[obj.user.pk])
|
||||||
|
return format_html('<a href="{}">{}</a>', url, obj.user.username)
|
||||||
|
return "-"
|
||||||
|
|
||||||
@admin.display(description="Status")
|
@admin.display(description="Status")
|
||||||
def is_expired(self, obj):
|
def expiration_status(self, obj):
|
||||||
from django.utils import timezone
|
"""Display expiration status with color coding."""
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
if timezone.now() - obj.last_sent > timedelta(days=1):
|
if timezone.now() - obj.last_sent > timedelta(days=1):
|
||||||
return format_html('<span style="color: red;">Expired</span>')
|
return format_html(
|
||||||
return format_html('<span style="color: green;">Valid</span>')
|
'<span style="color: red; font-weight: bold;">Expired</span>'
|
||||||
|
)
|
||||||
|
return format_html(
|
||||||
|
'<span style="color: green; font-weight: bold;">Valid</span>'
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.display(description="Can Resend", boolean=True)
|
||||||
|
def can_resend(self, obj):
|
||||||
|
"""Indicate if email can be resent (rate limited)."""
|
||||||
|
# Can resend if last sent more than 5 minutes ago
|
||||||
|
return timezone.now() - obj.last_sent > timedelta(minutes=5)
|
||||||
|
|
||||||
@admin.register(TopList)
|
@admin.action(description="Resend verification email")
|
||||||
class TopListAdmin(admin.ModelAdmin):
|
def resend_verification(self, request, queryset):
|
||||||
list_display = ("title", "user", "category", "created_at", "updated_at")
|
"""Resend verification emails."""
|
||||||
list_filter = ("category", "created_at", "updated_at")
|
count = 0
|
||||||
search_fields = ("title", "user__username", "description")
|
for verification in queryset:
|
||||||
inlines = [TopListItemInline]
|
if timezone.now() - verification.last_sent > timedelta(minutes=5):
|
||||||
|
verification.last_sent = timezone.now()
|
||||||
|
verification.save(update_fields=["last_sent"])
|
||||||
|
count += 1
|
||||||
|
self.message_user(request, f"Resent {count} verification emails.")
|
||||||
|
|
||||||
fieldsets = (
|
@admin.action(description="Delete expired tokens")
|
||||||
(
|
def delete_expired(self, request, queryset):
|
||||||
"Basic Information",
|
"""Delete expired verification tokens."""
|
||||||
{"fields": ("user", "title", "category", "description")},
|
cutoff = timezone.now() - timedelta(days=1)
|
||||||
),
|
expired = queryset.filter(last_sent__lt=cutoff)
|
||||||
(
|
count = expired.count()
|
||||||
"Timestamps",
|
expired.delete()
|
||||||
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
|
self.message_user(request, f"Deleted {count} expired tokens.")
|
||||||
),
|
|
||||||
)
|
|
||||||
readonly_fields = ("created_at", "updated_at")
|
|
||||||
|
|
||||||
|
def get_actions(self, request):
|
||||||
@admin.register(TopListItem)
|
"""Add custom actions."""
|
||||||
class TopListItemAdmin(admin.ModelAdmin):
|
actions = super().get_actions(request)
|
||||||
list_display = ("top_list", "content_type", "object_id", "rank")
|
actions["resend_verification"] = (
|
||||||
list_filter = ("top_list__category", "rank")
|
self.resend_verification,
|
||||||
search_fields = ("top_list__title", "notes")
|
"resend_verification",
|
||||||
ordering = ("top_list", "rank")
|
"Resend verification email",
|
||||||
|
)
|
||||||
fieldsets = (
|
actions["delete_expired"] = (
|
||||||
("List Information", {"fields": ("top_list", "rank")}),
|
self.delete_expired,
|
||||||
("Item Details", {"fields": ("content_type", "object_id", "notes")}),
|
"delete_expired",
|
||||||
)
|
"Delete expired tokens",
|
||||||
|
)
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
@admin.register(PasswordReset)
|
@admin.register(PasswordReset)
|
||||||
class PasswordResetAdmin(admin.ModelAdmin):
|
class PasswordResetAdmin(ReadOnlyAdminMixin, BaseModelAdmin):
|
||||||
"""Admin interface for password reset tokens"""
|
"""
|
||||||
|
Admin interface for password reset tokens.
|
||||||
|
|
||||||
|
Read-only admin for viewing password reset tokens.
|
||||||
|
Tokens should not be manually created or modified.
|
||||||
|
"""
|
||||||
|
|
||||||
list_display = (
|
list_display = (
|
||||||
"user",
|
"user_link",
|
||||||
"created_at",
|
"created_at",
|
||||||
"expires_at",
|
"expires_at",
|
||||||
"is_expired",
|
"status_badge",
|
||||||
"used",
|
"used",
|
||||||
)
|
)
|
||||||
list_filter = (
|
list_filter = ("used", "created_at", "expires_at")
|
||||||
"used",
|
list_select_related = ["user"]
|
||||||
"created_at",
|
search_fields = ("user__username", "user__email", "token")
|
||||||
"expires_at",
|
readonly_fields = ("token", "created_at", "expires_at", "user", "used")
|
||||||
)
|
|
||||||
search_fields = (
|
|
||||||
"user__username",
|
|
||||||
"user__email",
|
|
||||||
"token",
|
|
||||||
)
|
|
||||||
readonly_fields = (
|
|
||||||
"token",
|
|
||||||
"created_at",
|
|
||||||
"expires_at",
|
|
||||||
)
|
|
||||||
date_hierarchy = "created_at"
|
date_hierarchy = "created_at"
|
||||||
ordering = ("-created_at",)
|
ordering = ("-created_at",)
|
||||||
|
|
||||||
@@ -322,39 +605,66 @@ class PasswordResetAdmin(admin.ModelAdmin):
|
|||||||
(
|
(
|
||||||
"Reset Details",
|
"Reset Details",
|
||||||
{
|
{
|
||||||
"fields": (
|
"fields": ("user", "token", "used"),
|
||||||
"user",
|
"description": "Password reset token information.",
|
||||||
"token",
|
|
||||||
"used",
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Timing",
|
"Timing",
|
||||||
{
|
{
|
||||||
"fields": (
|
"fields": ("created_at", "expires_at"),
|
||||||
"created_at",
|
"description": "Token creation and expiration times.",
|
||||||
"expires_at",
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@admin.display(description="Status", boolean=True)
|
@admin.display(description="User")
|
||||||
def is_expired(self, obj):
|
def user_link(self, obj):
|
||||||
"""Display expiration status with color coding"""
|
"""Display user as clickable link."""
|
||||||
from django.utils import timezone
|
if obj.user:
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
url = reverse("admin:accounts_customuser_change", args=[obj.user.pk])
|
||||||
|
return format_html('<a href="{}">{}</a>', url, obj.user.username)
|
||||||
|
return "-"
|
||||||
|
|
||||||
|
@admin.display(description="Status")
|
||||||
|
def status_badge(self, obj):
|
||||||
|
"""Display status with color-coded badge."""
|
||||||
if obj.used:
|
if obj.used:
|
||||||
return format_html('<span style="color: blue;">Used</span>')
|
return format_html(
|
||||||
|
'<span style="background-color: blue; color: white; padding: 2px 8px; '
|
||||||
|
'border-radius: 4px; font-size: 11px;">Used</span>'
|
||||||
|
)
|
||||||
elif timezone.now() > obj.expires_at:
|
elif timezone.now() > obj.expires_at:
|
||||||
return format_html('<span style="color: red;">Expired</span>')
|
return format_html(
|
||||||
return format_html('<span style="color: green;">Valid</span>')
|
'<span style="background-color: red; color: white; padding: 2px 8px; '
|
||||||
|
'border-radius: 4px; font-size: 11px;">Expired</span>'
|
||||||
|
)
|
||||||
|
return format_html(
|
||||||
|
'<span style="background-color: green; color: white; padding: 2px 8px; '
|
||||||
|
'border-radius: 4px; font-size: 11px;">Valid</span>'
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.action(description="Cleanup old tokens")
|
||||||
|
def cleanup_old_tokens(self, request, queryset):
|
||||||
|
"""Delete old expired and used tokens."""
|
||||||
|
cutoff = timezone.now() - timedelta(days=7)
|
||||||
|
old_tokens = queryset.filter(created_at__lt=cutoff)
|
||||||
|
count = old_tokens.count()
|
||||||
|
old_tokens.delete()
|
||||||
|
self.message_user(request, f"Cleaned up {count} old tokens.")
|
||||||
|
|
||||||
|
def get_actions(self, request):
|
||||||
|
"""Add cleanup action."""
|
||||||
|
actions = super().get_actions(request)
|
||||||
|
if request.user.is_superuser:
|
||||||
|
actions["cleanup_old_tokens"] = (
|
||||||
|
self.cleanup_old_tokens,
|
||||||
|
"cleanup_old_tokens",
|
||||||
|
"Cleanup old tokens",
|
||||||
|
)
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
def has_add_permission(self, request):
|
|
||||||
"""Disable manual creation of password reset tokens"""
|
|
||||||
return False
|
|
||||||
|
|
||||||
def has_change_permission(self, request, obj=None):
|
|
||||||
"""Allow viewing but restrict editing of password reset tokens"""
|
|
||||||
return getattr(request.user, "is_superuser", False)
|
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ replacing tuple-based choices with rich, metadata-enhanced choice objects.
|
|||||||
Last updated: 2025-01-15
|
Last updated: 2025-01-15
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from apps.core.choices import RichChoice, ChoiceGroup, register_choices
|
from apps.core.choices import ChoiceGroup, RichChoice, register_choices
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# USER ROLES
|
# USER ROLES
|
||||||
@@ -112,6 +111,51 @@ theme_preferences = ChoiceGroup(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# UNIT SYSTEMS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
unit_systems = ChoiceGroup(
|
||||||
|
name="unit_systems",
|
||||||
|
choices=[
|
||||||
|
RichChoice(
|
||||||
|
value="metric",
|
||||||
|
label="Metric",
|
||||||
|
description="Use metric units (meters, km/h)",
|
||||||
|
metadata={
|
||||||
|
"color": "blue",
|
||||||
|
"icon": "ruler",
|
||||||
|
"css_class": "text-blue-600 bg-blue-50",
|
||||||
|
"units": {
|
||||||
|
"distance": "m",
|
||||||
|
"speed": "km/h",
|
||||||
|
"weight": "kg",
|
||||||
|
"large_distance": "km",
|
||||||
|
},
|
||||||
|
"sort_order": 1,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
RichChoice(
|
||||||
|
value="imperial",
|
||||||
|
label="Imperial",
|
||||||
|
description="Use imperial units (feet, mph)",
|
||||||
|
metadata={
|
||||||
|
"color": "green",
|
||||||
|
"icon": "ruler",
|
||||||
|
"css_class": "text-green-600 bg-green-50",
|
||||||
|
"units": {
|
||||||
|
"distance": "ft",
|
||||||
|
"speed": "mph",
|
||||||
|
"weight": "lbs",
|
||||||
|
"large_distance": "mi",
|
||||||
|
},
|
||||||
|
"sort_order": 2,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# PRIVACY LEVELS
|
# PRIVACY LEVELS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -557,6 +601,7 @@ notification_priorities = ChoiceGroup(
|
|||||||
# Register each choice group individually
|
# Register each choice group individually
|
||||||
register_choices("user_roles", user_roles.choices, "accounts", "User role classifications")
|
register_choices("user_roles", user_roles.choices, "accounts", "User role classifications")
|
||||||
register_choices("theme_preferences", theme_preferences.choices, "accounts", "Theme preference options")
|
register_choices("theme_preferences", theme_preferences.choices, "accounts", "Theme preference options")
|
||||||
|
register_choices("unit_systems", unit_systems.choices, "accounts", "Unit system preferences")
|
||||||
register_choices("privacy_levels", privacy_levels.choices, "accounts", "Privacy level settings")
|
register_choices("privacy_levels", privacy_levels.choices, "accounts", "Privacy level settings")
|
||||||
register_choices("top_list_categories", top_list_categories.choices, "accounts", "Top list category types")
|
register_choices("top_list_categories", top_list_categories.choices, "accounts", "Top list category types")
|
||||||
register_choices("notification_types", notification_types.choices, "accounts", "Notification type classifications")
|
register_choices("notification_types", notification_types.choices, "accounts", "Notification type classifications")
|
||||||
|
|||||||
94
backend/apps/accounts/export_service.py
Normal file
94
backend/apps/accounts/export_service.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .models import User
|
||||||
|
|
||||||
|
|
||||||
|
class UserExportService:
|
||||||
|
"""Service for exporting all user data."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def export_user_data(user: User) -> dict:
|
||||||
|
"""
|
||||||
|
Export all data associated with a user or an object containing counts/metadata and actual data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The user to export data for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: The complete user data export
|
||||||
|
"""
|
||||||
|
# Import models locally to avoid circular imports
|
||||||
|
from apps.lists.models import UserList
|
||||||
|
from apps.parks.models import ParkReview
|
||||||
|
from apps.rides.models import RideReview
|
||||||
|
|
||||||
|
# User account and profile
|
||||||
|
user_data = {
|
||||||
|
"username": user.username,
|
||||||
|
"email": user.email,
|
||||||
|
"date_joined": user.date_joined,
|
||||||
|
"first_name": user.first_name,
|
||||||
|
"last_name": user.last_name,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
"role": user.role,
|
||||||
|
}
|
||||||
|
|
||||||
|
profile_data = {}
|
||||||
|
if hasattr(user, "profile"):
|
||||||
|
profile = user.profile
|
||||||
|
profile_data = {
|
||||||
|
"display_name": profile.display_name,
|
||||||
|
"bio": profile.bio,
|
||||||
|
"location": profile.location,
|
||||||
|
"pronouns": profile.pronouns,
|
||||||
|
"unit_system": profile.unit_system,
|
||||||
|
"social_media": {
|
||||||
|
"twitter": profile.twitter,
|
||||||
|
"instagram": profile.instagram,
|
||||||
|
"youtube": profile.youtube,
|
||||||
|
"discord": profile.discord,
|
||||||
|
},
|
||||||
|
"ride_credits": {
|
||||||
|
"coaster": profile.coaster_credits,
|
||||||
|
"dark_ride": profile.dark_ride_credits,
|
||||||
|
"flat_ride": profile.flat_ride_credits,
|
||||||
|
"water_ride": profile.water_ride_credits,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reviews
|
||||||
|
park_reviews = list(ParkReview.objects.filter(user=user).values(
|
||||||
|
"park__name", "rating", "review", "created_at", "updated_at", "is_published"
|
||||||
|
))
|
||||||
|
|
||||||
|
ride_reviews = list(RideReview.objects.filter(user=user).values(
|
||||||
|
"ride__name", "rating", "review", "created_at", "updated_at", "is_published"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Lists
|
||||||
|
user_lists = []
|
||||||
|
for user_list in UserList.objects.filter(user=user):
|
||||||
|
items = list(user_list.items.values("order", "content_type__model", "object_id", "comment"))
|
||||||
|
user_lists.append({
|
||||||
|
"title": user_list.title,
|
||||||
|
"description": user_list.description,
|
||||||
|
"created_at": user_list.created_at,
|
||||||
|
"items": items
|
||||||
|
})
|
||||||
|
|
||||||
|
export_data = {
|
||||||
|
"account": user_data,
|
||||||
|
"profile": profile_data,
|
||||||
|
"preferences": getattr(user, "notification_preferences", {}),
|
||||||
|
"content": {
|
||||||
|
"park_reviews": park_reviews,
|
||||||
|
"ride_reviews": ride_reviews,
|
||||||
|
"lists": user_lists,
|
||||||
|
},
|
||||||
|
"export_info": {
|
||||||
|
"generated_at": timezone.now(),
|
||||||
|
"version": "1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return export_data
|
||||||
106
backend/apps/accounts/login_history.py
Normal file
106
backend/apps/accounts/login_history.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""
|
||||||
|
Login History Model
|
||||||
|
|
||||||
|
Tracks user login events for security auditing and compliance with
|
||||||
|
the login_history_retention setting on the User model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pghistory
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
@pghistory.track()
|
||||||
|
class LoginHistory(models.Model):
|
||||||
|
"""
|
||||||
|
Records each successful login attempt for a user.
|
||||||
|
|
||||||
|
Used for security auditing, login notifications, and compliance with
|
||||||
|
the user's login_history_retention preference.
|
||||||
|
"""
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="login_history",
|
||||||
|
help_text="User who logged in",
|
||||||
|
)
|
||||||
|
|
||||||
|
ip_address = models.GenericIPAddressField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="IP address from which the login occurred",
|
||||||
|
)
|
||||||
|
|
||||||
|
user_agent = models.CharField(
|
||||||
|
max_length=500,
|
||||||
|
blank=True,
|
||||||
|
help_text="Browser/client user agent string",
|
||||||
|
)
|
||||||
|
|
||||||
|
login_method = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=[
|
||||||
|
("PASSWORD", "Password"),
|
||||||
|
("GOOGLE", "Google OAuth"),
|
||||||
|
("DISCORD", "Discord OAuth"),
|
||||||
|
("MAGIC_LINK", "Magic Link"),
|
||||||
|
("SESSION", "Session Refresh"),
|
||||||
|
],
|
||||||
|
default="PASSWORD",
|
||||||
|
help_text="Method used for authentication",
|
||||||
|
)
|
||||||
|
|
||||||
|
login_timestamp = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="When the login occurred",
|
||||||
|
)
|
||||||
|
|
||||||
|
success = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Whether the login was successful",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optional geolocation data (can be populated asynchronously)
|
||||||
|
country = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
help_text="Country derived from IP (optional)",
|
||||||
|
)
|
||||||
|
|
||||||
|
city = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
help_text="City derived from IP (optional)",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Login History"
|
||||||
|
verbose_name_plural = "Login History"
|
||||||
|
ordering = ["-login_timestamp"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "-login_timestamp"]),
|
||||||
|
models.Index(fields=["ip_address"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} login at {self.login_timestamp}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def cleanup_old_entries(cls, days=90):
|
||||||
|
"""
|
||||||
|
Remove login history entries older than the specified number of days.
|
||||||
|
Respects each user's login_history_retention preference.
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
# Default cleanup for entries older than the specified days
|
||||||
|
cutoff = timezone.now() - timedelta(days=days)
|
||||||
|
deleted_count, _ = cls.objects.filter(
|
||||||
|
login_timestamp__lt=cutoff
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
return deleted_count
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.core.management.base import BaseCommand
|
from allauth.socialaccount.models import SocialAccount, SocialApp, SocialToken
|
||||||
from allauth.socialaccount.models import SocialApp, SocialAccount, SocialToken
|
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from allauth.socialaccount.models import SocialApp
|
from allauth.socialaccount.models import SocialApp
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from apps.parks.models import ParkReview, Park, ParkPhoto
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from apps.parks.models import Park, ParkPhoto, ParkReview
|
||||||
from apps.rides.models import Ride, RidePhoto
|
from apps.rides.models import Ride, RidePhoto
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@@ -52,8 +53,8 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test rides"))
|
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test rides"))
|
||||||
|
|
||||||
# Clean up test files
|
# Clean up test files
|
||||||
import os
|
|
||||||
import glob
|
import glob
|
||||||
|
import os
|
||||||
|
|
||||||
# Clean up test uploads
|
# Clean up test uploads
|
||||||
media_patterns = [
|
media_patterns = [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.contrib.sites.models import Site
|
|
||||||
from allauth.socialaccount.models import SocialApp
|
from allauth.socialaccount.models import SocialApp
|
||||||
|
from django.contrib.sites.models import Site
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.contrib.auth.models import Group, Permission, User
|
from django.contrib.auth.models import Group, Permission, User
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Usage:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
from apps.accounts.models import User
|
from apps.accounts.models import User
|
||||||
from apps.accounts.services import UserDeletionService
|
from apps.accounts.services import UserDeletionService
|
||||||
|
|
||||||
@@ -48,10 +49,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# Find the user
|
# Find the user
|
||||||
try:
|
try:
|
||||||
if username:
|
user = User.objects.get(username=username) if username else User.objects.get(user_id=user_id)
|
||||||
user = User.objects.get(username=username)
|
|
||||||
else:
|
|
||||||
user = User.objects.get(user_id=user_id)
|
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
identifier = username or user_id
|
identifier = username or user_id
|
||||||
raise CommandError(f'User "{identifier}" does not exist')
|
raise CommandError(f'User "{identifier}" does not exist')
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from django.core.management.base import BaseCommand
|
import os
|
||||||
|
|
||||||
from allauth.socialaccount.models import SocialApp
|
from allauth.socialaccount.models import SocialApp
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
import os
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
def generate_avatar(letter):
|
def generate_avatar(letter):
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from apps.accounts.models import UserProfile
|
from apps.accounts.models import UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
|
"""
|
||||||
|
Management command to reset the database and create an admin user.
|
||||||
|
|
||||||
|
Security Note: This command uses a mix of raw SQL (for PostgreSQL-specific operations
|
||||||
|
like dropping all tables) and Django ORM (for creating users). The raw SQL operations
|
||||||
|
use quote_ident() for table/sequence names which is safe from SQL injection.
|
||||||
|
|
||||||
|
WARNING: This command is destructive and should only be used in development.
|
||||||
|
"""
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.contrib.auth.hashers import make_password
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -10,7 +18,8 @@ class Command(BaseCommand):
|
|||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
self.stdout.write("Resetting database...")
|
self.stdout.write("Resetting database...")
|
||||||
|
|
||||||
# Drop all tables
|
# Drop all tables using PostgreSQL-specific operations
|
||||||
|
# Security: Using quote_ident() to safely quote table/sequence names
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
@@ -21,7 +30,7 @@ class Command(BaseCommand):
|
|||||||
SELECT tablename FROM pg_tables
|
SELECT tablename FROM pg_tables
|
||||||
WHERE schemaname = current_schema()
|
WHERE schemaname = current_schema()
|
||||||
) LOOP
|
) LOOP
|
||||||
EXECUTE 'DROP TABLE IF EXISTS ' || \
|
EXECUTE 'DROP TABLE IF EXISTS ' ||
|
||||||
quote_ident(r.tablename) || ' CASCADE';
|
quote_ident(r.tablename) || ' CASCADE';
|
||||||
END LOOP;
|
END LOOP;
|
||||||
END $$;
|
END $$;
|
||||||
@@ -38,7 +47,7 @@ class Command(BaseCommand):
|
|||||||
SELECT sequencename FROM pg_sequences
|
SELECT sequencename FROM pg_sequences
|
||||||
WHERE schemaname = current_schema()
|
WHERE schemaname = current_schema()
|
||||||
) LOOP
|
) LOOP
|
||||||
EXECUTE 'ALTER SEQUENCE ' || \
|
EXECUTE 'ALTER SEQUENCE ' ||
|
||||||
quote_ident(r.sequencename) || ' RESTART WITH 1';
|
quote_ident(r.sequencename) || ' RESTART WITH 1';
|
||||||
END LOOP;
|
END LOOP;
|
||||||
END $$;
|
END $$;
|
||||||
@@ -54,51 +63,25 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
self.stdout.write("Migrations applied.")
|
self.stdout.write("Migrations applied.")
|
||||||
|
|
||||||
# Create superuser using raw SQL
|
# Create superuser using Django ORM (safer than raw SQL)
|
||||||
try:
|
try:
|
||||||
with connection.cursor() as cursor:
|
from apps.accounts.models import User, UserProfile
|
||||||
# Create user
|
|
||||||
user_id = str(uuid.uuid4())[:10]
|
|
||||||
cursor.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO accounts_user (
|
|
||||||
username, password, email, is_superuser, is_staff,
|
|
||||||
is_active, date_joined, user_id, first_name,
|
|
||||||
last_name, role, is_banned, ban_reason,
|
|
||||||
theme_preference
|
|
||||||
) VALUES (
|
|
||||||
'admin', %s, 'admin@thrillwiki.com', true, true,
|
|
||||||
true, NOW(), %s, '', '', 'SUPERUSER', false, '',
|
|
||||||
'light'
|
|
||||||
) RETURNING id;
|
|
||||||
""",
|
|
||||||
[make_password("admin"), user_id],
|
|
||||||
)
|
|
||||||
|
|
||||||
result = cursor.fetchone()
|
# Security: Using Django ORM instead of raw SQL for user creation
|
||||||
if result is None:
|
user = User.objects.create_superuser(
|
||||||
raise Exception("Failed to create user - no ID returned")
|
username='admin',
|
||||||
user_db_id = result[0]
|
email='admin@thrillwiki.com',
|
||||||
|
password='admin',
|
||||||
|
role='SUPERUSER',
|
||||||
|
)
|
||||||
|
|
||||||
# Create profile
|
# Create profile using ORM
|
||||||
profile_id = str(uuid.uuid4())[:10]
|
UserProfile.objects.create(
|
||||||
cursor.execute(
|
user=user,
|
||||||
"""
|
display_name='Admin',
|
||||||
INSERT INTO accounts_userprofile (
|
pronouns='they/them',
|
||||||
profile_id, display_name, pronouns, bio,
|
bio='ThrillWiki Administrator',
|
||||||
twitter, instagram, youtube, discord,
|
)
|
||||||
coaster_credits, dark_ride_credits,
|
|
||||||
flat_ride_credits, water_ride_credits,
|
|
||||||
user_id, avatar
|
|
||||||
) VALUES (
|
|
||||||
%s, 'Admin', 'they/them', 'ThrillWiki Administrator',
|
|
||||||
'', '', '', '',
|
|
||||||
0, 0, 0, 0,
|
|
||||||
%s, ''
|
|
||||||
);
|
|
||||||
""",
|
|
||||||
[profile_id, user_db_id],
|
|
||||||
)
|
|
||||||
|
|
||||||
self.stdout.write("Superuser created.")
|
self.stdout.write("Superuser created.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from allauth.socialaccount.models import SocialApp
|
from allauth.socialaccount.models import SocialApp
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from apps.accounts.models import User
|
from apps.accounts.models import User
|
||||||
from apps.accounts.signals import create_default_groups
|
from apps.accounts.signals import create_default_groups
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.contrib.sites.models import Site
|
|
||||||
from allauth.socialaccount.models import SocialApp
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from allauth.socialaccount.models import SocialApp
|
||||||
|
from django.contrib.sites.models import Site
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Sets up social authentication apps"
|
help = "Sets up social authentication apps"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.contrib.sites.models import Site
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.sites.models import Site
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from allauth.socialaccount.models import SocialApp
|
from allauth.socialaccount.models import SocialApp
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
from allauth.socialaccount.models import SocialApp
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.test import Client
|
from django.test import Client
|
||||||
from allauth.socialaccount.models import SocialApp
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from allauth.socialaccount.models import SocialApp
|
from allauth.socialaccount.models import SocialApp
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from allauth.socialaccount.models import SocialApp
|
from allauth.socialaccount.models import SocialApp
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("accounts", "0002_remove_toplistevent_pgh_context_and_more"),
|
("accounts", "0002_remove_toplistevent_pgh_context_and_more"),
|
||||||
("pghistory", "0007_auto_20250421_0444"),
|
("pghistory", "0006_delete_aggregateevent"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class Migration(migrations.Migration):
|
|||||||
"accounts",
|
"accounts",
|
||||||
"0003_emailverificationevent_passwordresetevent_userevent_and_more",
|
"0003_emailverificationevent_passwordresetevent_userevent_and_more",
|
||||||
),
|
),
|
||||||
("pghistory", "0007_auto_20250421_0444"),
|
("pghistory", "0006_delete_aggregateevent"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class Migration(migrations.Migration):
|
|||||||
("accounts", "0008_remove_first_last_name_fields"),
|
("accounts", "0008_remove_first_last_name_fields"),
|
||||||
("contenttypes", "0002_remove_content_type_name"),
|
("contenttypes", "0002_remove_content_type_name"),
|
||||||
("django_cloudflareimages_toolkit", "0001_initial"),
|
("django_cloudflareimages_toolkit", "0001_initial"),
|
||||||
("pghistory", "0007_auto_20250421_0444"),
|
("pghistory", "0006_delete_aggregateevent"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# Generated by Django 5.2.5 on 2025-09-15 17:35
|
# Generated by Django 5.2.5 on 2025-09-15 17:35
|
||||||
|
|
||||||
import apps.core.choices.fields
|
|
||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
||||||
|
import apps.core.choices.fields
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""
|
||||||
|
Add performance indexes and constraints to User model.
|
||||||
|
|
||||||
|
This migration adds:
|
||||||
|
1. db_index=True to is_banned and role fields for faster filtering
|
||||||
|
2. Composite index on (is_banned, role) for common query patterns
|
||||||
|
3. CheckConstraint to ensure banned users have a ban_date set
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0012_alter_toplist_category_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Add db_index to is_banned field
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='user',
|
||||||
|
name='is_banned',
|
||||||
|
field=models.BooleanField(default=False, db_index=True),
|
||||||
|
),
|
||||||
|
# Add composite index for common query patterns
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='user',
|
||||||
|
index=models.Index(fields=['is_banned', 'role'], name='accounts_user_banned_role_idx'),
|
||||||
|
),
|
||||||
|
# Add CheckConstraint for ban consistency
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='user',
|
||||||
|
constraint=models.CheckConstraint(
|
||||||
|
name='user_ban_consistency',
|
||||||
|
check=models.Q(is_banned=False) | models.Q(ban_date__isnull=False),
|
||||||
|
violation_error_message='Banned users must have a ban_date set'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
|||||||
|
# Generated by Django 5.2.9 on 2025-12-27 20:58
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import pgtrigger.compiler
|
||||||
|
import pgtrigger.migrations
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("accounts", "0014_remove_toplist_user_remove_toplistitem_top_list_and_more"),
|
||||||
|
("pghistory", "0007_auto_20250421_0444"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="LoginHistory",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
(
|
||||||
|
"ip_address",
|
||||||
|
models.GenericIPAddressField(
|
||||||
|
blank=True, help_text="IP address from which the login occurred", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user_agent",
|
||||||
|
models.CharField(blank=True, help_text="Browser/client user agent string", max_length=500),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"login_method",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("PASSWORD", "Password"),
|
||||||
|
("GOOGLE", "Google OAuth"),
|
||||||
|
("DISCORD", "Discord OAuth"),
|
||||||
|
("MAGIC_LINK", "Magic Link"),
|
||||||
|
("SESSION", "Session Refresh"),
|
||||||
|
],
|
||||||
|
default="PASSWORD",
|
||||||
|
help_text="Method used for authentication",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"login_timestamp",
|
||||||
|
models.DateTimeField(auto_now_add=True, db_index=True, help_text="When the login occurred"),
|
||||||
|
),
|
||||||
|
("success", models.BooleanField(default=True, help_text="Whether the login was successful")),
|
||||||
|
(
|
||||||
|
"country",
|
||||||
|
models.CharField(blank=True, help_text="Country derived from IP (optional)", max_length=100),
|
||||||
|
),
|
||||||
|
("city", models.CharField(blank=True, help_text="City derived from IP (optional)", max_length=100)),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
help_text="User who logged in",
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="login_history",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Login History",
|
||||||
|
"verbose_name_plural": "Login History",
|
||||||
|
"ordering": ["-login_timestamp"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="LoginHistoryEvent",
|
||||||
|
fields=[
|
||||||
|
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||||
|
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("pgh_label", models.TextField(help_text="The event label.")),
|
||||||
|
("id", models.BigIntegerField()),
|
||||||
|
(
|
||||||
|
"ip_address",
|
||||||
|
models.GenericIPAddressField(
|
||||||
|
blank=True, help_text="IP address from which the login occurred", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user_agent",
|
||||||
|
models.CharField(blank=True, help_text="Browser/client user agent string", max_length=500),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"login_method",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("PASSWORD", "Password"),
|
||||||
|
("GOOGLE", "Google OAuth"),
|
||||||
|
("DISCORD", "Discord OAuth"),
|
||||||
|
("MAGIC_LINK", "Magic Link"),
|
||||||
|
("SESSION", "Session Refresh"),
|
||||||
|
],
|
||||||
|
default="PASSWORD",
|
||||||
|
help_text="Method used for authentication",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("login_timestamp", models.DateTimeField(auto_now_add=True, help_text="When the login occurred")),
|
||||||
|
("success", models.BooleanField(default=True, help_text="Whether the login was successful")),
|
||||||
|
(
|
||||||
|
"country",
|
||||||
|
models.CharField(blank=True, help_text="Country derived from IP (optional)", max_length=100),
|
||||||
|
),
|
||||||
|
("city", models.CharField(blank=True, help_text="City derived from IP (optional)", max_length=100)),
|
||||||
|
(
|
||||||
|
"pgh_context",
|
||||||
|
models.ForeignKey(
|
||||||
|
db_constraint=False,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
related_name="+",
|
||||||
|
to="pghistory.context",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"pgh_obj",
|
||||||
|
models.ForeignKey(
|
||||||
|
db_constraint=False,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
related_name="events",
|
||||||
|
to="accounts.loginhistory",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
db_constraint=False,
|
||||||
|
help_text="User who logged in",
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
related_name="+",
|
||||||
|
related_query_name="+",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="loginhistory",
|
||||||
|
index=models.Index(fields=["user", "-login_timestamp"], name="accounts_lo_user_id_156da7_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="loginhistory",
|
||||||
|
index=models.Index(fields=["ip_address"], name="accounts_lo_ip_addr_142937_idx"),
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="loginhistory",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="insert_insert",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
func='INSERT INTO "accounts_loginhistoryevent" ("city", "country", "id", "ip_address", "login_method", "login_timestamp", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "success", "user_agent", "user_id") VALUES (NEW."city", NEW."country", NEW."id", NEW."ip_address", NEW."login_method", NEW."login_timestamp", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."success", NEW."user_agent", NEW."user_id"); RETURN NULL;',
|
||||||
|
hash="9ccc4d52099a09097d02128eb427d58ae955a377",
|
||||||
|
operation="INSERT",
|
||||||
|
pgid="pgtrigger_insert_insert_dc41d",
|
||||||
|
table="accounts_loginhistory",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="loginhistory",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="update_update",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||||
|
func='INSERT INTO "accounts_loginhistoryevent" ("city", "country", "id", "ip_address", "login_method", "login_timestamp", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "success", "user_agent", "user_id") VALUES (NEW."city", NEW."country", NEW."id", NEW."ip_address", NEW."login_method", NEW."login_timestamp", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."success", NEW."user_agent", NEW."user_id"); RETURN NULL;',
|
||||||
|
hash="d5d998a5af1a55f181ebe8500a70022e8e4db724",
|
||||||
|
operation="UPDATE",
|
||||||
|
pgid="pgtrigger_update_update_110f5",
|
||||||
|
table="accounts_loginhistory",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,35 +1,44 @@
|
|||||||
import requests
|
"""
|
||||||
from django.conf import settings
|
Mixins for authentication views.
|
||||||
|
"""
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
from apps.core.utils.turnstile import get_client_ip, validate_turnstile_token
|
||||||
|
|
||||||
|
|
||||||
class TurnstileMixin:
|
class TurnstileMixin:
|
||||||
"""
|
"""
|
||||||
Mixin to handle Cloudflare Turnstile validation.
|
Mixin to handle Cloudflare Turnstile validation.
|
||||||
Bypasses validation when DEBUG is True.
|
Works with both form POST data and JSON request bodies.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def validate_turnstile(self, request):
|
def validate_turnstile(self, request):
|
||||||
"""
|
"""
|
||||||
Validate the Turnstile response token.
|
Validate the Turnstile response token.
|
||||||
Skips validation when DEBUG is True.
|
|
||||||
|
The token can be provided as:
|
||||||
|
- 'cf-turnstile-response' in POST data (form submission)
|
||||||
|
- 'turnstile_token' in JSON body (API request)
|
||||||
"""
|
"""
|
||||||
if settings.DEBUG:
|
# Try to get token from various sources
|
||||||
return
|
token = None
|
||||||
|
|
||||||
token = request.POST.get("cf-turnstile-response")
|
# Check POST data (form submissions)
|
||||||
if not token:
|
if hasattr(request, 'POST'):
|
||||||
raise ValidationError("Please complete the Turnstile challenge.")
|
token = request.POST.get("cf-turnstile-response")
|
||||||
|
|
||||||
# Verify the token with Cloudflare
|
# Check JSON body (API requests)
|
||||||
data = {
|
if not token and hasattr(request, 'data'):
|
||||||
"secret": settings.TURNSTILE_SECRET_KEY,
|
data = getattr(request, 'data', {})
|
||||||
"response": token,
|
if hasattr(data, 'get'):
|
||||||
"remoteip": request.META.get("REMOTE_ADDR"),
|
token = data.get('turnstile_token') or data.get('cf-turnstile-response')
|
||||||
}
|
|
||||||
|
|
||||||
response = requests.post(settings.TURNSTILE_VERIFY_URL, data=data, timeout=60)
|
# Get client IP
|
||||||
result = response.json()
|
ip = get_client_ip(request)
|
||||||
|
|
||||||
if not result.get("success"):
|
# Validate the token
|
||||||
raise ValidationError("Turnstile validation failed. Please try again.")
|
result = validate_turnstile_token(token, ip)
|
||||||
|
|
||||||
|
if not result.get('success'):
|
||||||
|
error_msg = result.get('error', 'Captcha verification failed. Please try again.')
|
||||||
|
raise ValidationError(error_msg)
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
from django.dispatch import receiver
|
import secrets
|
||||||
from django.db.models.signals import post_save
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pghistory
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
import secrets
|
|
||||||
from datetime import timedelta
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from apps.core.history import TrackedModel
|
|
||||||
from apps.core.choices import RichChoiceField
|
from apps.core.choices import RichChoiceField
|
||||||
import pghistory
|
from apps.core.history import TrackedModel
|
||||||
|
|
||||||
|
# from django_cloudflareimages_toolkit.models import CloudflareImage
|
||||||
|
|
||||||
|
|
||||||
def generate_random_id(model_class, id_field):
|
def generate_random_id(model_class, id_field):
|
||||||
@@ -49,21 +52,32 @@ class User(AbstractUser):
|
|||||||
domain="accounts",
|
domain="accounts",
|
||||||
max_length=10,
|
max_length=10,
|
||||||
default="USER",
|
default="USER",
|
||||||
|
db_index=True,
|
||||||
|
help_text="User role (user, moderator, admin)",
|
||||||
|
)
|
||||||
|
is_banned = models.BooleanField(
|
||||||
|
default=False, db_index=True, help_text="Whether this user is banned"
|
||||||
|
)
|
||||||
|
ban_reason = models.TextField(blank=True, help_text="Reason for ban")
|
||||||
|
ban_date = models.DateTimeField(
|
||||||
|
null=True, blank=True, help_text="Date the user was banned"
|
||||||
)
|
)
|
||||||
is_banned = models.BooleanField(default=False)
|
|
||||||
ban_reason = models.TextField(blank=True)
|
|
||||||
ban_date = models.DateTimeField(null=True, blank=True)
|
|
||||||
pending_email = models.EmailField(blank=True, null=True)
|
pending_email = models.EmailField(blank=True, null=True)
|
||||||
theme_preference = RichChoiceField(
|
theme_preference = RichChoiceField(
|
||||||
choice_group="theme_preferences",
|
choice_group="theme_preferences",
|
||||||
domain="accounts",
|
domain="accounts",
|
||||||
max_length=5,
|
max_length=5,
|
||||||
default="light",
|
default="light",
|
||||||
|
help_text="User's theme preference (light/dark)",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Notification preferences
|
# Notification preferences
|
||||||
email_notifications = models.BooleanField(default=True)
|
email_notifications = models.BooleanField(
|
||||||
push_notifications = models.BooleanField(default=False)
|
default=True, help_text="Whether to send email notifications"
|
||||||
|
)
|
||||||
|
push_notifications = models.BooleanField(
|
||||||
|
default=False, help_text="Whether to send push notifications"
|
||||||
|
)
|
||||||
|
|
||||||
# Privacy settings
|
# Privacy settings
|
||||||
privacy_level = RichChoiceField(
|
privacy_level = RichChoiceField(
|
||||||
@@ -71,31 +85,65 @@ class User(AbstractUser):
|
|||||||
domain="accounts",
|
domain="accounts",
|
||||||
max_length=10,
|
max_length=10,
|
||||||
default="public",
|
default="public",
|
||||||
|
help_text="Overall privacy level",
|
||||||
|
)
|
||||||
|
show_email = models.BooleanField(
|
||||||
|
default=False, help_text="Whether to show email on profile"
|
||||||
|
)
|
||||||
|
show_real_name = models.BooleanField(
|
||||||
|
default=True, help_text="Whether to show real name on profile"
|
||||||
|
)
|
||||||
|
show_join_date = models.BooleanField(
|
||||||
|
default=True, help_text="Whether to show join date on profile"
|
||||||
|
)
|
||||||
|
show_statistics = models.BooleanField(
|
||||||
|
default=True, help_text="Whether to show statistics on profile"
|
||||||
|
)
|
||||||
|
show_reviews = models.BooleanField(
|
||||||
|
default=True, help_text="Whether to show reviews on profile"
|
||||||
|
)
|
||||||
|
show_photos = models.BooleanField(
|
||||||
|
default=True, help_text="Whether to show photos on profile"
|
||||||
|
)
|
||||||
|
show_top_lists = models.BooleanField(
|
||||||
|
default=True, help_text="Whether to show top lists on profile"
|
||||||
|
)
|
||||||
|
allow_friend_requests = models.BooleanField(
|
||||||
|
default=True, help_text="Whether to allow friend requests"
|
||||||
|
)
|
||||||
|
allow_messages = models.BooleanField(
|
||||||
|
default=True, help_text="Whether to allow direct messages"
|
||||||
|
)
|
||||||
|
allow_profile_comments = models.BooleanField(
|
||||||
|
default=False, help_text="Whether to allow profile comments"
|
||||||
|
)
|
||||||
|
search_visibility = models.BooleanField(
|
||||||
|
default=True, help_text="Whether profile appears in search results"
|
||||||
)
|
)
|
||||||
show_email = models.BooleanField(default=False)
|
|
||||||
show_real_name = models.BooleanField(default=True)
|
|
||||||
show_join_date = models.BooleanField(default=True)
|
|
||||||
show_statistics = models.BooleanField(default=True)
|
|
||||||
show_reviews = models.BooleanField(default=True)
|
|
||||||
show_photos = models.BooleanField(default=True)
|
|
||||||
show_top_lists = models.BooleanField(default=True)
|
|
||||||
allow_friend_requests = models.BooleanField(default=True)
|
|
||||||
allow_messages = models.BooleanField(default=True)
|
|
||||||
allow_profile_comments = models.BooleanField(default=False)
|
|
||||||
search_visibility = models.BooleanField(default=True)
|
|
||||||
activity_visibility = RichChoiceField(
|
activity_visibility = RichChoiceField(
|
||||||
choice_group="privacy_levels",
|
choice_group="privacy_levels",
|
||||||
domain="accounts",
|
domain="accounts",
|
||||||
max_length=10,
|
max_length=10,
|
||||||
default="friends",
|
default="friends",
|
||||||
|
help_text="Who can see user activity",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Security settings
|
# Security settings
|
||||||
two_factor_enabled = models.BooleanField(default=False)
|
two_factor_enabled = models.BooleanField(
|
||||||
login_notifications = models.BooleanField(default=True)
|
default=False, help_text="Whether two-factor authentication is enabled"
|
||||||
session_timeout = models.IntegerField(default=30) # days
|
)
|
||||||
login_history_retention = models.IntegerField(default=90) # days
|
login_notifications = models.BooleanField(
|
||||||
last_password_change = models.DateTimeField(auto_now_add=True)
|
default=True, help_text="Whether to send login notifications"
|
||||||
|
)
|
||||||
|
session_timeout = models.IntegerField(
|
||||||
|
default=30, help_text="Session timeout in days"
|
||||||
|
)
|
||||||
|
login_history_retention = models.IntegerField(
|
||||||
|
default=90, help_text="How long to retain login history (days)"
|
||||||
|
)
|
||||||
|
last_password_change = models.DateTimeField(
|
||||||
|
auto_now_add=True, help_text="When the password was last changed"
|
||||||
|
)
|
||||||
|
|
||||||
# Display name - core user data for better performance
|
# Display name - core user data for better performance
|
||||||
display_name = models.CharField(
|
display_name = models.CharField(
|
||||||
@@ -127,6 +175,20 @@ class User(AbstractUser):
|
|||||||
return profile.display_name
|
return profile.display_name
|
||||||
return self.username
|
return self.username
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "User"
|
||||||
|
verbose_name_plural = "Users"
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['is_banned', 'role'], name='accounts_user_banned_role_idx'),
|
||||||
|
]
|
||||||
|
constraints = [
|
||||||
|
models.CheckConstraint(
|
||||||
|
name='user_ban_consistency',
|
||||||
|
check=models.Q(is_banned=False) | models.Q(ban_date__isnull=False),
|
||||||
|
violation_error_message='Banned users must have a ban_date set'
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.user_id:
|
if not self.user_id:
|
||||||
self.user_id = generate_random_id(User, "user_id")
|
self.user_id = generate_random_id(User, "user_id")
|
||||||
@@ -143,33 +205,60 @@ class UserProfile(models.Model):
|
|||||||
help_text="Unique identifier for this profile that remains constant",
|
help_text="Unique identifier for this profile that remains constant",
|
||||||
)
|
)
|
||||||
|
|
||||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
user = models.OneToOneField(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="profile",
|
||||||
|
help_text="User this profile belongs to",
|
||||||
|
)
|
||||||
display_name = models.CharField(
|
display_name = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Legacy display name field - use User.display_name instead",
|
help_text="Legacy display name field - use User.display_name instead",
|
||||||
)
|
)
|
||||||
avatar = models.ForeignKey(
|
avatar = models.ForeignKey(
|
||||||
'django_cloudflareimages_toolkit.CloudflareImage',
|
"django_cloudflareimages_toolkit.CloudflareImage",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True
|
blank=True,
|
||||||
|
related_name="user_profiles",
|
||||||
|
help_text="User's avatar image",
|
||||||
|
)
|
||||||
|
pronouns = models.CharField(
|
||||||
|
max_length=50, blank=True, help_text="User's preferred pronouns"
|
||||||
)
|
)
|
||||||
pronouns = models.CharField(max_length=50, blank=True)
|
|
||||||
|
|
||||||
bio = models.TextField(max_length=500, blank=True)
|
bio = models.TextField(max_length=500, blank=True, help_text="User biography")
|
||||||
|
location = models.CharField(
|
||||||
|
max_length=100, blank=True, help_text="User's location (City, Country)"
|
||||||
|
)
|
||||||
|
unit_system = RichChoiceField(
|
||||||
|
choice_group="unit_systems",
|
||||||
|
domain="accounts",
|
||||||
|
max_length=10,
|
||||||
|
default="metric",
|
||||||
|
help_text="Preferred measurement system",
|
||||||
|
)
|
||||||
|
|
||||||
# Social media links
|
# Social media links
|
||||||
twitter = models.URLField(blank=True)
|
twitter = models.URLField(blank=True, help_text="Twitter profile URL")
|
||||||
instagram = models.URLField(blank=True)
|
instagram = models.URLField(blank=True, help_text="Instagram profile URL")
|
||||||
youtube = models.URLField(blank=True)
|
youtube = models.URLField(blank=True, help_text="YouTube channel URL")
|
||||||
discord = models.CharField(max_length=100, blank=True)
|
discord = models.CharField(max_length=100, blank=True, help_text="Discord username")
|
||||||
|
|
||||||
# Ride statistics
|
# Ride statistics
|
||||||
coaster_credits = models.IntegerField(default=0)
|
coaster_credits = models.IntegerField(
|
||||||
dark_ride_credits = models.IntegerField(default=0)
|
default=0, help_text="Number of roller coasters ridden"
|
||||||
flat_ride_credits = models.IntegerField(default=0)
|
)
|
||||||
water_ride_credits = models.IntegerField(default=0)
|
dark_ride_credits = models.IntegerField(
|
||||||
|
default=0, help_text="Number of dark rides ridden"
|
||||||
|
)
|
||||||
|
flat_ride_credits = models.IntegerField(
|
||||||
|
default=0, help_text="Number of flat rides ridden"
|
||||||
|
)
|
||||||
|
water_ride_credits = models.IntegerField(
|
||||||
|
default=0, help_text="Number of water rides ridden"
|
||||||
|
)
|
||||||
|
|
||||||
def get_avatar_url(self):
|
def get_avatar_url(self):
|
||||||
"""
|
"""
|
||||||
@@ -252,13 +341,31 @@ class UserProfile(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.display_name
|
return self.display_name
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "User Profile"
|
||||||
|
verbose_name_plural = "User Profiles"
|
||||||
|
ordering = ["user"]
|
||||||
|
|
||||||
|
|
||||||
@pghistory.track()
|
@pghistory.track()
|
||||||
class EmailVerification(models.Model):
|
class EmailVerification(models.Model):
|
||||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
user = models.OneToOneField(
|
||||||
token = models.CharField(max_length=64, unique=True)
|
User,
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
on_delete=models.CASCADE,
|
||||||
last_sent = models.DateTimeField(auto_now_add=True)
|
help_text="User this verification belongs to",
|
||||||
|
)
|
||||||
|
token = models.CharField(
|
||||||
|
max_length=64, unique=True, help_text="Verification token"
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(
|
||||||
|
auto_now_add=True, help_text="When this verification was created"
|
||||||
|
)
|
||||||
|
updated_at = models.DateTimeField(
|
||||||
|
auto_now=True, help_text="When this verification was last updated"
|
||||||
|
)
|
||||||
|
last_sent = models.DateTimeField(
|
||||||
|
auto_now_add=True, help_text="When the verification email was last sent"
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Email verification for {self.user.username}"
|
return f"Email verification for {self.user.username}"
|
||||||
@@ -270,11 +377,17 @@ class EmailVerification(models.Model):
|
|||||||
|
|
||||||
@pghistory.track()
|
@pghistory.track()
|
||||||
class PasswordReset(models.Model):
|
class PasswordReset(models.Model):
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.ForeignKey(
|
||||||
token = models.CharField(max_length=64)
|
User,
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
on_delete=models.CASCADE,
|
||||||
expires_at = models.DateTimeField()
|
help_text="User requesting password reset",
|
||||||
used = models.BooleanField(default=False)
|
)
|
||||||
|
token = models.CharField(max_length=64, help_text="Reset token")
|
||||||
|
created_at = models.DateTimeField(
|
||||||
|
auto_now_add=True, help_text="When this reset was requested"
|
||||||
|
)
|
||||||
|
expires_at = models.DateTimeField(help_text="When this reset token expires")
|
||||||
|
used = models.BooleanField(default=False, help_text="Whether this token has been used")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Password reset for {self.user.username}"
|
return f"Password reset for {self.user.username}"
|
||||||
@@ -284,54 +397,6 @@ class PasswordReset(models.Model):
|
|||||||
verbose_name_plural = "Password Resets"
|
verbose_name_plural = "Password Resets"
|
||||||
|
|
||||||
|
|
||||||
# @pghistory.track()
|
|
||||||
|
|
||||||
|
|
||||||
class TopList(TrackedModel):
|
|
||||||
user = models.ForeignKey(
|
|
||||||
User,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="top_lists", # Added related_name for User model access
|
|
||||||
)
|
|
||||||
title = models.CharField(max_length=100)
|
|
||||||
category = RichChoiceField(
|
|
||||||
choice_group="top_list_categories",
|
|
||||||
domain="accounts",
|
|
||||||
max_length=2,
|
|
||||||
)
|
|
||||||
description = models.TextField(blank=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta(TrackedModel.Meta):
|
|
||||||
ordering = ["-updated_at"]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return (
|
|
||||||
f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# @pghistory.track()
|
|
||||||
|
|
||||||
|
|
||||||
class TopListItem(TrackedModel):
|
|
||||||
top_list = models.ForeignKey(
|
|
||||||
TopList, on_delete=models.CASCADE, related_name="items"
|
|
||||||
)
|
|
||||||
content_type = models.ForeignKey(
|
|
||||||
"contenttypes.ContentType", on_delete=models.CASCADE
|
|
||||||
)
|
|
||||||
object_id = models.PositiveIntegerField()
|
|
||||||
rank = models.PositiveIntegerField()
|
|
||||||
notes = models.TextField(blank=True)
|
|
||||||
|
|
||||||
class Meta(TrackedModel.Meta):
|
|
||||||
ordering = ["rank"]
|
|
||||||
unique_together = [["top_list", "rank"]]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"#{self.rank} in {self.top_list.title}"
|
|
||||||
|
|
||||||
|
|
||||||
@pghistory.track()
|
@pghistory.track()
|
||||||
@@ -374,6 +439,8 @@ class UserDeletionRequest(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
verbose_name = "User Deletion Request"
|
||||||
|
verbose_name_plural = "User Deletion Requests"
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=["verification_code"]),
|
models.Index(fields=["verification_code"]),
|
||||||
@@ -451,7 +518,10 @@ class UserNotification(TrackedModel):
|
|||||||
|
|
||||||
# Core fields
|
# Core fields
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
User, on_delete=models.CASCADE, related_name="notifications"
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="notifications",
|
||||||
|
help_text="User this notification is for",
|
||||||
)
|
)
|
||||||
|
|
||||||
notification_type = RichChoiceField(
|
notification_type = RichChoiceField(
|
||||||
@@ -460,14 +530,20 @@ class UserNotification(TrackedModel):
|
|||||||
max_length=30,
|
max_length=30,
|
||||||
)
|
)
|
||||||
|
|
||||||
title = models.CharField(max_length=200)
|
title = models.CharField(max_length=200, help_text="Notification title")
|
||||||
message = models.TextField()
|
message = models.TextField(help_text="Notification message")
|
||||||
|
|
||||||
# Optional related object (submission, review, etc.)
|
# Optional related object (submission, review, etc.)
|
||||||
content_type = models.ForeignKey(
|
content_type = models.ForeignKey(
|
||||||
"contenttypes.ContentType", on_delete=models.CASCADE, null=True, blank=True
|
"contenttypes.ContentType",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Type of related object",
|
||||||
|
)
|
||||||
|
object_id = models.PositiveIntegerField(
|
||||||
|
null=True, blank=True, help_text="ID of related object"
|
||||||
)
|
)
|
||||||
object_id = models.PositiveIntegerField(null=True, blank=True)
|
|
||||||
related_object = GenericForeignKey("content_type", "object_id")
|
related_object = GenericForeignKey("content_type", "object_id")
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
@@ -479,14 +555,24 @@ class UserNotification(TrackedModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Status tracking
|
# Status tracking
|
||||||
is_read = models.BooleanField(default=False)
|
is_read = models.BooleanField(
|
||||||
read_at = models.DateTimeField(null=True, blank=True)
|
default=False, help_text="Whether this notification has been read"
|
||||||
|
)
|
||||||
|
read_at = models.DateTimeField(
|
||||||
|
null=True, blank=True, help_text="When this notification was read"
|
||||||
|
)
|
||||||
|
|
||||||
# Delivery tracking
|
# Delivery tracking
|
||||||
email_sent = models.BooleanField(default=False)
|
email_sent = models.BooleanField(default=False, help_text="Whether email was sent")
|
||||||
email_sent_at = models.DateTimeField(null=True, blank=True)
|
email_sent_at = models.DateTimeField(
|
||||||
push_sent = models.BooleanField(default=False)
|
null=True, blank=True, help_text="When email was sent"
|
||||||
push_sent_at = models.DateTimeField(null=True, blank=True)
|
)
|
||||||
|
push_sent = models.BooleanField(
|
||||||
|
default=False, help_text="Whether push notification was sent"
|
||||||
|
)
|
||||||
|
push_sent_at = models.DateTimeField(
|
||||||
|
null=True, blank=True, help_text="When push notification was sent"
|
||||||
|
)
|
||||||
|
|
||||||
# Additional data (JSON field for flexibility)
|
# Additional data (JSON field for flexibility)
|
||||||
extra_data = models.JSONField(default=dict, blank=True)
|
extra_data = models.JSONField(default=dict, blank=True)
|
||||||
@@ -496,6 +582,8 @@ class UserNotification(TrackedModel):
|
|||||||
expires_at = models.DateTimeField(null=True, blank=True)
|
expires_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
class Meta(TrackedModel.Meta):
|
class Meta(TrackedModel.Meta):
|
||||||
|
verbose_name = "User Notification"
|
||||||
|
verbose_name_plural = "User Notifications"
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=["user", "is_read"]),
|
models.Index(fields=["user", "is_read"]),
|
||||||
@@ -546,7 +634,10 @@ class NotificationPreference(TrackedModel):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
user = models.OneToOneField(
|
user = models.OneToOneField(
|
||||||
User, on_delete=models.CASCADE, related_name="notification_preference"
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="notification_preference",
|
||||||
|
help_text="User these preferences belong to",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Submission notifications
|
# Submission notifications
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ Selectors for user and account-related data retrieval.
|
|||||||
Following Django styleguide pattern for separating data access from business logic.
|
Following Django styleguide pattern for separating data access from business logic.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, Any
|
|
||||||
from django.db.models import QuerySet, Q, F, Count
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.utils import timezone
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db.models import Count, F, Q, QuerySet
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
@@ -196,7 +197,7 @@ def users_with_social_accounts() -> QuerySet:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def user_statistics_summary() -> Dict[str, Any]:
|
def user_statistics_summary() -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get overall user statistics for dashboard/analytics.
|
Get overall user statistics for dashboard/analytics.
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
from rest_framework import serializers
|
from datetime import timedelta
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.password_validation import validate_password
|
from django.contrib.auth.password_validation import validate_password
|
||||||
from django.utils.crypto import get_random_string
|
|
||||||
from django.utils import timezone
|
|
||||||
from datetime import timedelta
|
|
||||||
from django.contrib.sites.shortcuts import get_current_site
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
from .models import User, PasswordReset
|
|
||||||
from django_forwardemail.services import EmailService
|
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from typing import cast
|
from django.utils import timezone
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
|
from django_forwardemail.services import EmailService
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from .models import PasswordReset, User
|
||||||
|
|
||||||
UserModel = get_user_model()
|
UserModel = get_user_model()
|
||||||
|
|
||||||
@@ -19,7 +21,9 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
avatar_url = serializers.SerializerMethodField()
|
avatar_url = serializers.SerializerMethodField()
|
||||||
display_name = serializers.SerializerMethodField()
|
display_name = serializers.CharField(source="profile.display_name", required=False)
|
||||||
|
unit_system = serializers.CharField(source="profile.unit_system", required=False)
|
||||||
|
location = serializers.CharField(source="profile.location", required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
@@ -31,6 +35,8 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
"date_joined",
|
"date_joined",
|
||||||
"is_active",
|
"is_active",
|
||||||
"avatar_url",
|
"avatar_url",
|
||||||
|
"unit_system",
|
||||||
|
"location",
|
||||||
]
|
]
|
||||||
read_only_fields = ["id", "date_joined", "is_active"]
|
read_only_fields = ["id", "date_joined", "is_active"]
|
||||||
|
|
||||||
@@ -40,9 +46,15 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
return obj.profile.avatar.url
|
return obj.profile.avatar.url
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_display_name(self, obj) -> str:
|
def update(self, instance, validated_data):
|
||||||
"""Get user display name"""
|
profile_data = validated_data.pop("profile", {})
|
||||||
return obj.get_display_name()
|
profile = instance.profile
|
||||||
|
|
||||||
|
for attr, value in profile_data.items():
|
||||||
|
setattr(profile, attr, value)
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
class LoginSerializer(serializers.Serializer):
|
class LoginSerializer(serializers.Serializer):
|
||||||
|
|||||||
@@ -2,16 +2,281 @@
|
|||||||
User management services for ThrillWiki.
|
User management services for ThrillWiki.
|
||||||
|
|
||||||
This module contains services for user account management including
|
This module contains services for user account management including
|
||||||
user deletion while preserving submissions.
|
user deletion while preserving submissions, password management,
|
||||||
|
and email change functionality.
|
||||||
|
|
||||||
|
Recent additions:
|
||||||
|
- AccountService: Handles password and email change operations
|
||||||
|
- UserDeletionService: Manages user deletion while preserving content
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional
|
import logging
|
||||||
from django.db import transaction
|
import re
|
||||||
from django.utils import timezone
|
from typing import Any
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import update_session_auth_hash
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
|
from django.db import transaction
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
from django_forwardemail.services import EmailService
|
from django_forwardemail.services import EmailService
|
||||||
from .models import User, UserProfile, UserDeletionRequest
|
|
||||||
|
from .models import EmailVerification, User, UserDeletionRequest, UserProfile
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountService:
|
||||||
|
"""Service for account management operations including password and email changes."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_password(password: str) -> bool:
|
||||||
|
"""
|
||||||
|
Validate password meets requirements.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
password: The password to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if password meets requirements, False otherwise
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
len(password) >= 8
|
||||||
|
and bool(re.search(r"[A-Z]", password))
|
||||||
|
and bool(re.search(r"[a-z]", password))
|
||||||
|
and bool(re.search(r"[0-9]", password))
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def change_password(
|
||||||
|
*,
|
||||||
|
user: User,
|
||||||
|
old_password: str,
|
||||||
|
new_password: str,
|
||||||
|
request: HttpRequest,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Change user password with validation and notification.
|
||||||
|
|
||||||
|
Validates the old password, checks new password requirements,
|
||||||
|
updates the password, and sends a confirmation email.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The user whose password is being changed
|
||||||
|
old_password: Current password for verification
|
||||||
|
new_password: New password to set
|
||||||
|
request: HTTP request for session handling
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with success status, message, and optional redirect URL:
|
||||||
|
{
|
||||||
|
'success': bool,
|
||||||
|
'message': str,
|
||||||
|
'redirect_url': Optional[str]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# Verify old password
|
||||||
|
if not user.check_password(old_password):
|
||||||
|
logger.warning(
|
||||||
|
f"Password change failed: incorrect current password for user {user.id}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': "Current password is incorrect",
|
||||||
|
'redirect_url': None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate new password
|
||||||
|
if not AccountService.validate_password(new_password):
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': "Password must be at least 8 characters and contain uppercase, lowercase, and numbers",
|
||||||
|
'redirect_url': None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update password
|
||||||
|
user.set_password(new_password)
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
# Keep user logged in after password change
|
||||||
|
update_session_auth_hash(request, user)
|
||||||
|
|
||||||
|
# Send confirmation email
|
||||||
|
AccountService._send_password_change_confirmation(request, user)
|
||||||
|
|
||||||
|
logger.info(f"Password changed successfully for user {user.id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message': "Password changed successfully. Please check your email for confirmation.",
|
||||||
|
'redirect_url': None
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _send_password_change_confirmation(request: HttpRequest, user: User) -> None:
|
||||||
|
"""Send password change confirmation email."""
|
||||||
|
site = get_current_site(request)
|
||||||
|
context = {
|
||||||
|
"user": user,
|
||||||
|
"site_name": site.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
email_html = render_to_string(
|
||||||
|
"accounts/email/password_change_confirmation.html", context
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
EmailService.send_email(
|
||||||
|
to=user.email,
|
||||||
|
subject="Password Changed Successfully",
|
||||||
|
text="Your password has been changed successfully.",
|
||||||
|
site=site,
|
||||||
|
html=email_html,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send password change confirmation email: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def initiate_email_change(
|
||||||
|
*,
|
||||||
|
user: User,
|
||||||
|
new_email: str,
|
||||||
|
request: HttpRequest,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Initiate email change with verification.
|
||||||
|
|
||||||
|
Creates a verification token and sends a verification email
|
||||||
|
to the new email address.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The user changing their email
|
||||||
|
new_email: The new email address
|
||||||
|
request: HTTP request for site context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with success status and message:
|
||||||
|
{
|
||||||
|
'success': bool,
|
||||||
|
'message': str
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if not new_email:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': "New email is required"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if email is already in use
|
||||||
|
if User.objects.filter(email=new_email).exclude(id=user.id).exists():
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': "This email address is already in use"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate verification token
|
||||||
|
token = get_random_string(64)
|
||||||
|
|
||||||
|
# Create or update email verification record
|
||||||
|
EmailVerification.objects.update_or_create(
|
||||||
|
user=user,
|
||||||
|
defaults={"token": token}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store pending email
|
||||||
|
user.pending_email = new_email
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
# Send verification email
|
||||||
|
AccountService._send_email_verification(request, user, new_email, token)
|
||||||
|
|
||||||
|
logger.info(f"Email change initiated for user {user.id} to {new_email}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message': "Verification email sent to your new email address"
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _send_email_verification(
|
||||||
|
request: HttpRequest,
|
||||||
|
user: User,
|
||||||
|
new_email: str,
|
||||||
|
token: str
|
||||||
|
) -> None:
|
||||||
|
"""Send email verification for email change."""
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
site = get_current_site(request)
|
||||||
|
verification_url = reverse("verify_email", kwargs={"token": token})
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"user": user,
|
||||||
|
"verification_url": verification_url,
|
||||||
|
"site_name": site.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
email_html = render_to_string("accounts/email/verify_email.html", context)
|
||||||
|
|
||||||
|
try:
|
||||||
|
EmailService.send_email(
|
||||||
|
to=new_email,
|
||||||
|
subject="Verify your new email address",
|
||||||
|
text="Click the link to verify your new email address",
|
||||||
|
site=site,
|
||||||
|
html=email_html,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send email verification: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def verify_email_change(*, token: str) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Verify email change token and update user email.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The verification token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with success status and message
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
verification = EmailVerification.objects.select_related("user").get(
|
||||||
|
token=token
|
||||||
|
)
|
||||||
|
except EmailVerification.DoesNotExist:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': "Invalid or expired verification token"
|
||||||
|
}
|
||||||
|
|
||||||
|
user = verification.user
|
||||||
|
|
||||||
|
if not user.pending_email:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': "No pending email change found"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update email
|
||||||
|
old_email = user.email
|
||||||
|
user.email = user.pending_email
|
||||||
|
user.pending_email = None
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
# Delete verification record
|
||||||
|
verification.delete()
|
||||||
|
|
||||||
|
logger.info(f"Email changed for user {user.id} from {old_email} to {user.email}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message': "Email address updated successfully"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class UserDeletionService:
|
class UserDeletionService:
|
||||||
@@ -110,35 +375,35 @@ class UserDeletionService:
|
|||||||
# Transfer all submissions to deleted user
|
# Transfer all submissions to deleted user
|
||||||
# Reviews
|
# Reviews
|
||||||
if hasattr(user, "park_reviews"):
|
if hasattr(user, "park_reviews"):
|
||||||
getattr(user, "park_reviews").update(user=deleted_user)
|
user.park_reviews.update(user=deleted_user)
|
||||||
if hasattr(user, "ride_reviews"):
|
if hasattr(user, "ride_reviews"):
|
||||||
getattr(user, "ride_reviews").update(user=deleted_user)
|
user.ride_reviews.update(user=deleted_user)
|
||||||
|
|
||||||
# Photos
|
# Photos
|
||||||
if hasattr(user, "uploaded_park_photos"):
|
if hasattr(user, "uploaded_park_photos"):
|
||||||
getattr(user, "uploaded_park_photos").update(uploaded_by=deleted_user)
|
user.uploaded_park_photos.update(uploaded_by=deleted_user)
|
||||||
if hasattr(user, "uploaded_ride_photos"):
|
if hasattr(user, "uploaded_ride_photos"):
|
||||||
getattr(user, "uploaded_ride_photos").update(uploaded_by=deleted_user)
|
user.uploaded_ride_photos.update(uploaded_by=deleted_user)
|
||||||
|
|
||||||
# Top Lists
|
# Top Lists
|
||||||
if hasattr(user, "top_lists"):
|
if hasattr(user, "top_lists"):
|
||||||
getattr(user, "top_lists").update(user=deleted_user)
|
user.top_lists.update(user=deleted_user)
|
||||||
|
|
||||||
# Moderation submissions
|
# Moderation submissions
|
||||||
if hasattr(user, "edit_submissions"):
|
if hasattr(user, "edit_submissions"):
|
||||||
getattr(user, "edit_submissions").update(user=deleted_user)
|
user.edit_submissions.update(user=deleted_user)
|
||||||
if hasattr(user, "photo_submissions"):
|
if hasattr(user, "photo_submissions"):
|
||||||
getattr(user, "photo_submissions").update(user=deleted_user)
|
user.photo_submissions.update(user=deleted_user)
|
||||||
|
|
||||||
# Moderation actions - these can be set to NULL since they're not user content
|
# Moderation actions - these can be set to NULL since they're not user content
|
||||||
if hasattr(user, "moderated_park_reviews"):
|
if hasattr(user, "moderated_park_reviews"):
|
||||||
getattr(user, "moderated_park_reviews").update(moderated_by=None)
|
user.moderated_park_reviews.update(moderated_by=None)
|
||||||
if hasattr(user, "moderated_ride_reviews"):
|
if hasattr(user, "moderated_ride_reviews"):
|
||||||
getattr(user, "moderated_ride_reviews").update(moderated_by=None)
|
user.moderated_ride_reviews.update(moderated_by=None)
|
||||||
if hasattr(user, "handled_submissions"):
|
if hasattr(user, "handled_submissions"):
|
||||||
getattr(user, "handled_submissions").update(handled_by=None)
|
user.handled_submissions.update(handled_by=None)
|
||||||
if hasattr(user, "handled_photos"):
|
if hasattr(user, "handled_photos"):
|
||||||
getattr(user, "handled_photos").update(handled_by=None)
|
user.handled_photos.update(handled_by=None)
|
||||||
|
|
||||||
# Store user info for the summary
|
# Store user info for the summary
|
||||||
user_info = {
|
user_info = {
|
||||||
@@ -161,7 +426,7 @@ class UserDeletionService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def can_delete_user(cls, user: User) -> tuple[bool, Optional[str]]:
|
def can_delete_user(cls, user: User) -> tuple[bool, str | None]:
|
||||||
"""
|
"""
|
||||||
Check if a user can be safely deleted.
|
Check if a user can be safely deleted.
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,19 @@ This service handles the creation, delivery, and management of notifications
|
|||||||
for various events including submission approvals/rejections.
|
for various events including submission approvals/rejections.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.template.loader import render_to_string
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import models
|
|
||||||
from typing import Optional, Dict, Any, List
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from apps.accounts.models import User, UserNotification, NotificationPreference
|
from django.conf import settings
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.db import models
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils import timezone
|
||||||
from django_forwardemail.services import EmailService
|
from django_forwardemail.services import EmailService
|
||||||
|
|
||||||
|
from apps.accounts.models import NotificationPreference, User, UserNotification
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -29,10 +30,10 @@ class NotificationService:
|
|||||||
notification_type: str,
|
notification_type: str,
|
||||||
title: str,
|
title: str,
|
||||||
message: str,
|
message: str,
|
||||||
related_object: Optional[Any] = None,
|
related_object: Any | None = None,
|
||||||
priority: str = UserNotification.Priority.NORMAL,
|
priority: str = UserNotification.Priority.NORMAL,
|
||||||
extra_data: Optional[Dict[str, Any]] = None,
|
extra_data: dict[str, Any] | None = None,
|
||||||
expires_at: Optional[datetime] = None,
|
expires_at: datetime | None = None,
|
||||||
) -> UserNotification:
|
) -> UserNotification:
|
||||||
"""
|
"""
|
||||||
Create a new notification for a user.
|
Create a new notification for a user.
|
||||||
@@ -273,9 +274,9 @@ class NotificationService:
|
|||||||
def get_user_notifications(
|
def get_user_notifications(
|
||||||
user: User,
|
user: User,
|
||||||
unread_only: bool = False,
|
unread_only: bool = False,
|
||||||
notification_types: Optional[List[str]] = None,
|
notification_types: list[str] | None = None,
|
||||||
limit: Optional[int] = None,
|
limit: int | None = None,
|
||||||
) -> List[UserNotification]:
|
) -> list[UserNotification]:
|
||||||
"""
|
"""
|
||||||
Get notifications for a user.
|
Get notifications for a user.
|
||||||
|
|
||||||
@@ -308,7 +309,7 @@ class NotificationService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def mark_notifications_read(
|
def mark_notifications_read(
|
||||||
user: User, notification_ids: Optional[List[int]] = None
|
user: User, notification_ids: list[int] | None = None
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
Mark notifications as read for a user.
|
Mark notifications as read for a user.
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ social authentication providers while ensuring users never lock themselves
|
|||||||
out of their accounts.
|
out of their accounts.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, List, Tuple, TYPE_CHECKING
|
import logging
|
||||||
from django.contrib.auth import get_user_model
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from allauth.socialaccount.models import SocialApp
|
from allauth.socialaccount.models import SocialApp
|
||||||
from allauth.socialaccount.providers import registry
|
from allauth.socialaccount.providers import registry
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.sites.shortcuts import get_current_site
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
import logging
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from apps.accounts.models import User
|
from apps.accounts.models import User
|
||||||
@@ -26,7 +27,7 @@ class SocialProviderService:
|
|||||||
"""Service for managing social provider connections."""
|
"""Service for managing social provider connections."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def can_disconnect_provider(user: User, provider: str) -> Tuple[bool, str]:
|
def can_disconnect_provider(user: User, provider: str) -> tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Check if a user can safely disconnect a social provider.
|
Check if a user can safely disconnect a social provider.
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ class SocialProviderService:
|
|||||||
return False, "Unable to verify disconnection safety. Please try again."
|
return False, "Unable to verify disconnection safety. Please try again."
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_connected_providers(user: "User") -> List[Dict]:
|
def get_connected_providers(user: "User") -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Get all social providers connected to a user's account.
|
Get all social providers connected to a user's account.
|
||||||
|
|
||||||
@@ -106,7 +107,7 @@ class SocialProviderService:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_available_providers(request: HttpRequest) -> List[Dict]:
|
def get_available_providers(request: HttpRequest) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Get all available social providers for the current site.
|
Get all available social providers for the current site.
|
||||||
|
|
||||||
@@ -152,7 +153,7 @@ class SocialProviderService:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def disconnect_provider(user: "User", provider: str) -> Tuple[bool, str]:
|
def disconnect_provider(user: "User", provider: str) -> tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Disconnect a social provider from a user's account.
|
Disconnect a social provider from a user's account.
|
||||||
|
|
||||||
@@ -191,7 +192,7 @@ class SocialProviderService:
|
|||||||
return False, f"Failed to disconnect {provider} account. Please try again."
|
return False, f"Failed to disconnect {provider} account. Please try again."
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_auth_status(user: "User") -> Dict:
|
def get_auth_status(user: "User") -> dict:
|
||||||
"""
|
"""
|
||||||
Get comprehensive authentication status for a user.
|
Get comprehensive authentication status for a user.
|
||||||
|
|
||||||
@@ -231,7 +232,7 @@ class SocialProviderService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def validate_provider_exists(provider: str) -> Tuple[bool, str]:
|
def validate_provider_exists(provider: str) -> tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Validate that a social provider is configured and available.
|
Validate that a social provider is configured and available.
|
||||||
|
|
||||||
|
|||||||
@@ -5,19 +5,18 @@ This service handles user account deletion while preserving submissions
|
|||||||
and maintaining data integrity across the platform.
|
and maintaining data integrity across the platform.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.db import transaction
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.core.mail import send_mail
|
|
||||||
from django.conf import settings
|
|
||||||
from django.template.loader import render_to_string
|
|
||||||
from typing import Dict, Any, Tuple, Optional
|
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from apps.accounts.models import User
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.db import transaction
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -41,7 +40,7 @@ class UserDeletionService:
|
|||||||
_deletion_requests = {}
|
_deletion_requests = {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def can_delete_user(user: User) -> Tuple[bool, Optional[str]]:
|
def can_delete_user(user: User) -> tuple[bool, str | None]:
|
||||||
"""
|
"""
|
||||||
Check if a user can be safely deleted.
|
Check if a user can be safely deleted.
|
||||||
|
|
||||||
@@ -104,7 +103,7 @@ class UserDeletionService:
|
|||||||
return deletion_request
|
return deletion_request
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def verify_and_delete_user(verification_code: str) -> Dict[str, Any]:
|
def verify_and_delete_user(verification_code: str) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Verify deletion code and delete user account.
|
Verify deletion code and delete user account.
|
||||||
|
|
||||||
@@ -169,7 +168,7 @@ class UserDeletionService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def delete_user_preserve_submissions(user: User) -> Dict[str, Any]:
|
def delete_user_preserve_submissions(user: User) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Delete a user account while preserving all their submissions.
|
Delete a user account while preserving all their submissions.
|
||||||
|
|
||||||
@@ -217,7 +216,7 @@ class UserDeletionService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _count_user_submissions(user: User) -> Dict[str, int]:
|
def _count_user_submissions(user: User) -> dict[str, int]:
|
||||||
"""Count all submissions for a user."""
|
"""Count all submissions for a user."""
|
||||||
counts = {}
|
counts = {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
from django.db.models.signals import post_save, pre_save
|
import requests
|
||||||
from django.dispatch import receiver
|
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.db import transaction
|
from django.contrib.auth.signals import user_logged_in
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.core.files.temp import NamedTemporaryFile
|
from django.core.files.temp import NamedTemporaryFile
|
||||||
import requests
|
from django.db import transaction
|
||||||
|
from django.db.models.signals import post_save, pre_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from .login_history import LoginHistory
|
||||||
from .models import User, UserProfile
|
from .models import User, UserProfile
|
||||||
|
|
||||||
|
|
||||||
@@ -185,3 +188,41 @@ def create_default_groups():
|
|||||||
print(f"Permission not found: {codename}")
|
print(f"Permission not found: {codename}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error creating default groups: {str(e)}")
|
print(f"Error creating default groups: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(user_logged_in)
|
||||||
|
def log_successful_login(sender, user, request, **kwargs):
|
||||||
|
"""
|
||||||
|
Log successful login events to LoginHistory.
|
||||||
|
|
||||||
|
This signal handler captures the IP address, user agent, and login method
|
||||||
|
for auditing and security purposes.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get IP address
|
||||||
|
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||||
|
ip_address = x_forwarded_for.split(',')[0].strip() if x_forwarded_for else request.META.get('REMOTE_ADDR')
|
||||||
|
|
||||||
|
# Get user agent
|
||||||
|
user_agent = request.META.get('HTTP_USER_AGENT', '')[:500]
|
||||||
|
|
||||||
|
# Determine login method from session or request
|
||||||
|
login_method = 'PASSWORD'
|
||||||
|
if hasattr(request, 'session'):
|
||||||
|
sociallogin = getattr(request, '_sociallogin', None)
|
||||||
|
if sociallogin:
|
||||||
|
provider = sociallogin.account.provider.upper()
|
||||||
|
if provider in ['GOOGLE', 'DISCORD']:
|
||||||
|
login_method = provider
|
||||||
|
|
||||||
|
# Create login history entry
|
||||||
|
LoginHistory.objects.create(
|
||||||
|
user=user,
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent,
|
||||||
|
login_method=login_method,
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Don't let login history failure prevent login
|
||||||
|
print(f"Error logging login history for user {user.username}: {str(e)}")
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from django.test import TestCase
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from django.contrib.auth.models import Group, Permission
|
from django.contrib.auth.models import Group, Permission
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from unittest.mock import patch, MagicMock
|
from django.test import TestCase
|
||||||
|
|
||||||
from .models import User, UserProfile
|
from .models import User, UserProfile
|
||||||
from .signals import create_default_groups
|
from .signals import create_default_groups
|
||||||
|
|
||||||
|
|||||||
0
backend/apps/accounts/tests/__init__.py
Normal file
0
backend/apps/accounts/tests/__init__.py
Normal file
155
backend/apps/accounts/tests/test_admin.py
Normal file
155
backend/apps/accounts/tests/test_admin.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
"""
|
||||||
|
Tests for accounts admin interfaces.
|
||||||
|
|
||||||
|
These tests verify the functionality of user, profile, email verification,
|
||||||
|
password reset, and top list admin classes including query optimization
|
||||||
|
and custom actions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.contrib.admin.sites import AdminSite
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import RequestFactory, TestCase
|
||||||
|
|
||||||
|
from apps.accounts.admin import (
|
||||||
|
CustomUserAdmin,
|
||||||
|
EmailVerificationAdmin,
|
||||||
|
PasswordResetAdmin,
|
||||||
|
UserProfileAdmin,
|
||||||
|
)
|
||||||
|
from apps.accounts.models import (
|
||||||
|
EmailVerification,
|
||||||
|
PasswordReset,
|
||||||
|
User,
|
||||||
|
UserProfile,
|
||||||
|
)
|
||||||
|
|
||||||
|
UserModel = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCustomUserAdmin(TestCase):
|
||||||
|
"""Tests for CustomUserAdmin class."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.site = AdminSite()
|
||||||
|
self.admin = CustomUserAdmin(model=User, admin_site=self.site)
|
||||||
|
|
||||||
|
def test_list_display_fields(self):
|
||||||
|
"""Verify all required fields are in list_display."""
|
||||||
|
required_fields = [
|
||||||
|
"username",
|
||||||
|
"email",
|
||||||
|
"get_avatar",
|
||||||
|
"get_status_badge",
|
||||||
|
"role",
|
||||||
|
"date_joined",
|
||||||
|
]
|
||||||
|
for field in required_fields:
|
||||||
|
assert field in self.admin.list_display
|
||||||
|
|
||||||
|
def test_list_select_related(self):
|
||||||
|
"""Verify select_related is configured for profile."""
|
||||||
|
assert "profile" in self.admin.list_select_related
|
||||||
|
|
||||||
|
def test_list_prefetch_related(self):
|
||||||
|
"""Verify prefetch_related is configured for groups."""
|
||||||
|
assert "groups" in self.admin.list_prefetch_related
|
||||||
|
|
||||||
|
def test_user_actions_registered(self):
|
||||||
|
"""Verify user management actions are registered."""
|
||||||
|
assert "activate_users" in self.admin.actions
|
||||||
|
assert "deactivate_users" in self.admin.actions
|
||||||
|
assert "ban_users" in self.admin.actions
|
||||||
|
assert "unban_users" in self.admin.actions
|
||||||
|
|
||||||
|
def test_export_fields_configured(self):
|
||||||
|
"""Verify export fields are configured."""
|
||||||
|
assert hasattr(self.admin, "export_fields")
|
||||||
|
assert "username" in self.admin.export_fields
|
||||||
|
assert "email" in self.admin.export_fields
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserProfileAdmin(TestCase):
|
||||||
|
"""Tests for UserProfileAdmin class."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.site = AdminSite()
|
||||||
|
self.admin = UserProfileAdmin(model=UserProfile, admin_site=self.site)
|
||||||
|
|
||||||
|
def test_list_select_related(self):
|
||||||
|
"""Verify select_related for user."""
|
||||||
|
assert "user" in self.admin.list_select_related
|
||||||
|
|
||||||
|
def test_recalculate_action(self):
|
||||||
|
"""Verify recalculate credits action exists."""
|
||||||
|
request = self.factory.get("/admin/")
|
||||||
|
request.user = UserModel(is_superuser=True)
|
||||||
|
|
||||||
|
actions = self.admin.get_actions(request)
|
||||||
|
assert "recalculate_credits" in actions
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmailVerificationAdmin(TestCase):
|
||||||
|
"""Tests for EmailVerificationAdmin class."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.site = AdminSite()
|
||||||
|
self.admin = EmailVerificationAdmin(model=EmailVerification, admin_site=self.site)
|
||||||
|
|
||||||
|
def test_list_select_related(self):
|
||||||
|
"""Verify select_related for user."""
|
||||||
|
assert "user" in self.admin.list_select_related
|
||||||
|
|
||||||
|
def test_readonly_fields(self):
|
||||||
|
"""Verify token fields are readonly."""
|
||||||
|
assert "token" in self.admin.readonly_fields
|
||||||
|
assert "created_at" in self.admin.readonly_fields
|
||||||
|
|
||||||
|
def test_verification_actions(self):
|
||||||
|
"""Verify verification actions exist."""
|
||||||
|
request = self.factory.get("/admin/")
|
||||||
|
request.user = UserModel(is_superuser=True)
|
||||||
|
|
||||||
|
actions = self.admin.get_actions(request)
|
||||||
|
assert "resend_verification" in actions
|
||||||
|
assert "delete_expired" in actions
|
||||||
|
|
||||||
|
|
||||||
|
class TestPasswordResetAdmin(TestCase):
|
||||||
|
"""Tests for PasswordResetAdmin class."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.site = AdminSite()
|
||||||
|
self.admin = PasswordResetAdmin(model=PasswordReset, admin_site=self.site)
|
||||||
|
|
||||||
|
def test_readonly_permissions(self):
|
||||||
|
"""Verify read-only permissions are set."""
|
||||||
|
request = self.factory.get("/admin/")
|
||||||
|
request.user = UserModel(is_superuser=False)
|
||||||
|
|
||||||
|
assert self.admin.has_add_permission(request) is False
|
||||||
|
assert self.admin.has_change_permission(request) is False
|
||||||
|
|
||||||
|
def test_list_select_related(self):
|
||||||
|
"""Verify select_related for user."""
|
||||||
|
assert "user" in self.admin.list_select_related
|
||||||
|
|
||||||
|
def test_cleanup_action_superuser_only(self):
|
||||||
|
"""Verify cleanup action is superuser only."""
|
||||||
|
request = self.factory.get("/admin/")
|
||||||
|
|
||||||
|
# Non-superuser shouldn't see cleanup action
|
||||||
|
request.user = UserModel(is_superuser=False)
|
||||||
|
actions = self.admin.get_actions(request)
|
||||||
|
assert "cleanup_old_tokens" not in actions
|
||||||
|
|
||||||
|
# Superuser should see cleanup action
|
||||||
|
request.user = UserModel(is_superuser=True)
|
||||||
|
actions = self.admin.get_actions(request)
|
||||||
|
assert "cleanup_old_tokens" in actions
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
100
backend/apps/accounts/tests/test_model_constraints.py
Normal file
100
backend/apps/accounts/tests/test_model_constraints.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""
|
||||||
|
Tests for model constraints and validators in the accounts app.
|
||||||
|
|
||||||
|
These tests verify that:
|
||||||
|
1. CheckConstraints raise appropriate errors
|
||||||
|
2. Validators work correctly
|
||||||
|
3. Business rules are enforced at the model level
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import IntegrityError
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class UserConstraintTests(TestCase):
|
||||||
|
"""Tests for User model constraints."""
|
||||||
|
|
||||||
|
def test_banned_user_without_ban_date_raises_error(self):
|
||||||
|
"""Verify banned users must have a ban_date set."""
|
||||||
|
user = User(
|
||||||
|
username="testuser",
|
||||||
|
email="test@example.com",
|
||||||
|
is_banned=True,
|
||||||
|
ban_date=None, # This should violate the constraint
|
||||||
|
)
|
||||||
|
|
||||||
|
# The constraint should be enforced at database level
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
def test_banned_user_with_ban_date_saves_successfully(self):
|
||||||
|
"""Verify banned users with ban_date save successfully."""
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username="testuser2",
|
||||||
|
email="test2@example.com",
|
||||||
|
password="testpass123",
|
||||||
|
is_banned=True,
|
||||||
|
ban_date=timezone.now(),
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(user.pk)
|
||||||
|
self.assertTrue(user.is_banned)
|
||||||
|
self.assertIsNotNone(user.ban_date)
|
||||||
|
|
||||||
|
def test_non_banned_user_without_ban_date_saves_successfully(self):
|
||||||
|
"""Verify non-banned users can be saved without ban_date."""
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username="testuser3",
|
||||||
|
email="test3@example.com",
|
||||||
|
password="testpass123",
|
||||||
|
is_banned=False,
|
||||||
|
ban_date=None,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(user.pk)
|
||||||
|
self.assertFalse(user.is_banned)
|
||||||
|
|
||||||
|
def test_user_id_is_auto_generated(self):
|
||||||
|
"""Verify user_id is automatically generated on save."""
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username="testuser4",
|
||||||
|
email="test4@example.com",
|
||||||
|
password="testpass123",
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(user.user_id)
|
||||||
|
self.assertTrue(len(user.user_id) >= 4)
|
||||||
|
|
||||||
|
def test_user_id_is_unique(self):
|
||||||
|
"""Verify user_id is unique across users."""
|
||||||
|
user1 = User.objects.create_user(
|
||||||
|
username="testuser5",
|
||||||
|
email="test5@example.com",
|
||||||
|
password="testpass123",
|
||||||
|
)
|
||||||
|
user2 = User.objects.create_user(
|
||||||
|
username="testuser6",
|
||||||
|
email="test6@example.com",
|
||||||
|
password="testpass123",
|
||||||
|
)
|
||||||
|
self.assertNotEqual(user1.user_id, user2.user_id)
|
||||||
|
|
||||||
|
|
||||||
|
class UserIndexTests(TestCase):
|
||||||
|
"""Tests for User model indexes."""
|
||||||
|
|
||||||
|
def test_is_banned_field_is_indexed(self):
|
||||||
|
"""Verify is_banned field has db_index=True."""
|
||||||
|
field = User._meta.get_field('is_banned')
|
||||||
|
self.assertTrue(field.db_index)
|
||||||
|
|
||||||
|
def test_role_field_is_indexed(self):
|
||||||
|
"""Verify role field has db_index=True."""
|
||||||
|
field = User._meta.get_field('role')
|
||||||
|
self.assertTrue(field.db_index)
|
||||||
|
|
||||||
|
def test_composite_index_exists(self):
|
||||||
|
"""Verify composite index on (is_banned, role) exists."""
|
||||||
|
indexes = User._meta.indexes
|
||||||
|
index_names = [idx.name for idx in indexes]
|
||||||
|
self.assertIn('accounts_user_banned_role_idx', index_names)
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
Tests for user deletion while preserving submissions.
|
Tests for user deletion while preserving submissions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from apps.accounts.services import UserDeletionService
|
from django.test import TestCase
|
||||||
|
|
||||||
from apps.accounts.models import User, UserProfile
|
from apps.accounts.models import User, UserProfile
|
||||||
|
from apps.accounts.services import UserDeletionService
|
||||||
|
|
||||||
|
|
||||||
class UserDeletionServiceTest(TestCase):
|
class UserDeletionServiceTest(TestCase):
|
||||||
@@ -140,13 +141,12 @@ class UserDeletionServiceTest(TestCase):
|
|||||||
original_user_count = User.objects.count()
|
original_user_count = User.objects.count()
|
||||||
|
|
||||||
# Mock a failure during the deletion process
|
# Mock a failure during the deletion process
|
||||||
with self.assertRaises(Exception):
|
with self.assertRaises(Exception), transaction.atomic():
|
||||||
with transaction.atomic():
|
# Start the deletion process
|
||||||
# Start the deletion process
|
UserDeletionService.get_or_create_deleted_user()
|
||||||
UserDeletionService.get_or_create_deleted_user()
|
|
||||||
|
|
||||||
# Simulate an error
|
# Simulate an error
|
||||||
raise Exception("Simulated error during deletion")
|
raise Exception("Simulated error during deletion")
|
||||||
|
|
||||||
# Verify user count hasn't changed
|
# Verify user count hasn't changed
|
||||||
self.assertEqual(User.objects.count(), original_user_count)
|
self.assertEqual(User.objects.count(), original_user_count)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from django.urls import path
|
|
||||||
from django.contrib.auth import views as auth_views
|
|
||||||
from allauth.account.views import LogoutView
|
from allauth.account.views import LogoutView
|
||||||
|
from django.contrib.auth import views as auth_views
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
app_name = "accounts"
|
app_name = "accounts"
|
||||||
|
|||||||
@@ -1,38 +1,44 @@
|
|||||||
from django.views.generic import DetailView, TemplateView
|
import logging
|
||||||
from django.contrib.auth import get_user_model
|
import re
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from contextlib import suppress
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from allauth.account.views import LoginView, SignupView
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth import get_user_model, login
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.contrib import messages
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.template.loader import render_to_string
|
|
||||||
from django.utils.crypto import get_random_string
|
|
||||||
from django.utils import timezone
|
|
||||||
from datetime import timedelta
|
|
||||||
from django.contrib.sites.shortcuts import get_current_site
|
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
from django.contrib.sites.requests import RequestSite
|
from django.contrib.sites.requests import RequestSite
|
||||||
from django.db.models import QuerySet
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
from django.http import HttpResponseRedirect, HttpResponse, HttpRequest
|
from django.core.exceptions import ValidationError
|
||||||
from django.urls import reverse
|
|
||||||
from django.contrib.auth import login
|
|
||||||
from django.core.files.uploadedfile import UploadedFile
|
from django.core.files.uploadedfile import UploadedFile
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
|
from django.views.generic import DetailView, TemplateView
|
||||||
|
from django_forwardemail.services import EmailService
|
||||||
|
from django_htmx.http import HttpResponseClientRefresh
|
||||||
|
|
||||||
from apps.accounts.models import (
|
from apps.accounts.models import (
|
||||||
User,
|
|
||||||
PasswordReset,
|
|
||||||
TopList,
|
|
||||||
EmailVerification,
|
EmailVerification,
|
||||||
|
PasswordReset,
|
||||||
|
User,
|
||||||
UserProfile,
|
UserProfile,
|
||||||
)
|
)
|
||||||
from django_forwardemail.services import EmailService
|
from apps.core.logging import log_security_event
|
||||||
|
from apps.lists.models import UserList
|
||||||
from apps.parks.models import ParkReview
|
from apps.parks.models import ParkReview
|
||||||
from apps.rides.models import RideReview
|
from apps.rides.models import RideReview
|
||||||
from allauth.account.views import LoginView, SignupView
|
|
||||||
from .mixins import TurnstileMixin
|
from .mixins import TurnstileMixin
|
||||||
from typing import Dict, Any, Optional, Union, cast
|
|
||||||
from django_htmx.http import HttpResponseClientRefresh
|
logger = logging.getLogger(__name__)
|
||||||
from contextlib import suppress
|
|
||||||
import re
|
|
||||||
|
|
||||||
UserModel = get_user_model()
|
UserModel = get_user_model()
|
||||||
|
|
||||||
@@ -46,6 +52,15 @@ class CustomLoginView(TurnstileMixin, LoginView):
|
|||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
|
|
||||||
response = super().form_valid(form)
|
response = super().form_valid(form)
|
||||||
|
user = self.request.user
|
||||||
|
log_security_event(
|
||||||
|
logger,
|
||||||
|
event_type="user_login",
|
||||||
|
message=f"User {user.username} logged in successfully",
|
||||||
|
severity="low",
|
||||||
|
context={"user_id": user.id, "username": user.username},
|
||||||
|
request=self.request,
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
HttpResponseClientRefresh()
|
HttpResponseClientRefresh()
|
||||||
if getattr(self.request, "htmx", False)
|
if getattr(self.request, "htmx", False)
|
||||||
@@ -53,6 +68,14 @@ class CustomLoginView(TurnstileMixin, LoginView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def form_invalid(self, form):
|
def form_invalid(self, form):
|
||||||
|
log_security_event(
|
||||||
|
logger,
|
||||||
|
event_type="login_failed",
|
||||||
|
message="Failed login attempt",
|
||||||
|
severity="medium",
|
||||||
|
context={"username": form.data.get("login", "unknown")},
|
||||||
|
request=self.request,
|
||||||
|
)
|
||||||
if getattr(self.request, "htmx", False):
|
if getattr(self.request, "htmx", False):
|
||||||
return render(
|
return render(
|
||||||
self.request,
|
self.request,
|
||||||
@@ -80,6 +103,19 @@ class CustomSignupView(TurnstileMixin, SignupView):
|
|||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
|
|
||||||
response = super().form_valid(form)
|
response = super().form_valid(form)
|
||||||
|
user = self.user
|
||||||
|
log_security_event(
|
||||||
|
logger,
|
||||||
|
event_type="user_signup",
|
||||||
|
message=f"New user registered: {user.username}",
|
||||||
|
severity="low",
|
||||||
|
context={
|
||||||
|
"user_id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"email": user.email,
|
||||||
|
},
|
||||||
|
request=self.request,
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
HttpResponseClientRefresh()
|
HttpResponseClientRefresh()
|
||||||
if getattr(self.request, "htmx", False)
|
if getattr(self.request, "htmx", False)
|
||||||
@@ -149,7 +185,7 @@ class ProfileView(DetailView):
|
|||||||
def get_queryset(self) -> QuerySet[User]:
|
def get_queryset(self) -> QuerySet[User]:
|
||||||
return User.objects.select_related("profile")
|
return User.objects.select_related("profile")
|
||||||
|
|
||||||
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
|
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
user = cast(User, self.get_object())
|
user = cast(User, self.get_object())
|
||||||
|
|
||||||
@@ -173,9 +209,9 @@ class ProfileView(DetailView):
|
|||||||
.order_by("-created_at")[:5]
|
.order_by("-created_at")[:5]
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_user_top_lists(self, user: User) -> QuerySet[TopList]:
|
def _get_user_top_lists(self, user: User) -> QuerySet[UserList]:
|
||||||
return (
|
return (
|
||||||
TopList.objects.filter(user=user)
|
UserList.objects.filter(user=user)
|
||||||
.select_related("user", "user__profile")
|
.select_related("user", "user__profile")
|
||||||
.prefetch_related("items")
|
.prefetch_related("items")
|
||||||
.order_by("-created_at")[:5]
|
.order_by("-created_at")[:5]
|
||||||
@@ -185,7 +221,7 @@ class ProfileView(DetailView):
|
|||||||
class SettingsView(LoginRequiredMixin, TemplateView):
|
class SettingsView(LoginRequiredMixin, TemplateView):
|
||||||
template_name = "accounts/settings.html"
|
template_name = "accounts/settings.html"
|
||||||
|
|
||||||
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
|
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context["user"] = self.request.user
|
context["user"] = self.request.user
|
||||||
return context
|
return context
|
||||||
@@ -197,12 +233,22 @@ class SettingsView(LoginRequiredMixin, TemplateView):
|
|||||||
if display_name := request.POST.get("display_name"):
|
if display_name := request.POST.get("display_name"):
|
||||||
profile.display_name = display_name
|
profile.display_name = display_name
|
||||||
|
|
||||||
|
if unit_system := request.POST.get("unit_system"):
|
||||||
|
profile.unit_system = unit_system
|
||||||
|
|
||||||
|
if location := request.POST.get("location"):
|
||||||
|
profile.location = location
|
||||||
|
|
||||||
if "avatar" in request.FILES:
|
if "avatar" in request.FILES:
|
||||||
avatar_file = cast(UploadedFile, request.FILES["avatar"])
|
avatar_file = cast(UploadedFile, request.FILES["avatar"])
|
||||||
profile.avatar.save(avatar_file.name, avatar_file, save=False)
|
profile.avatar.save(avatar_file.name, avatar_file, save=False)
|
||||||
profile.save()
|
profile.save()
|
||||||
|
|
||||||
user.save()
|
user.save()
|
||||||
|
logger.info(
|
||||||
|
f"User {user.username} updated their profile",
|
||||||
|
extra={"user_id": user.id, "username": user.username},
|
||||||
|
)
|
||||||
messages.success(request, "Profile updated successfully")
|
messages.success(request, "Profile updated successfully")
|
||||||
|
|
||||||
def _validate_password(self, password: str) -> bool:
|
def _validate_password(self, password: str) -> bool:
|
||||||
@@ -238,7 +284,7 @@ class SettingsView(LoginRequiredMixin, TemplateView):
|
|||||||
|
|
||||||
def _handle_password_change(
|
def _handle_password_change(
|
||||||
self, request: HttpRequest
|
self, request: HttpRequest
|
||||||
) -> Optional[HttpResponseRedirect]:
|
) -> HttpResponseRedirect | None:
|
||||||
user = cast(User, request.user)
|
user = cast(User, request.user)
|
||||||
old_password = request.POST.get("old_password", "")
|
old_password = request.POST.get("old_password", "")
|
||||||
new_password = request.POST.get("new_password", "")
|
new_password = request.POST.get("new_password", "")
|
||||||
@@ -262,6 +308,15 @@ class SettingsView(LoginRequiredMixin, TemplateView):
|
|||||||
user.set_password(new_password)
|
user.set_password(new_password)
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
|
log_security_event(
|
||||||
|
logger,
|
||||||
|
event_type="password_changed",
|
||||||
|
message=f"User {user.username} changed their password",
|
||||||
|
severity="medium",
|
||||||
|
context={"user_id": user.id, "username": user.username},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
self._send_password_change_confirmation(request, user)
|
self._send_password_change_confirmation(request, user)
|
||||||
messages.success(
|
messages.success(
|
||||||
request,
|
request,
|
||||||
@@ -331,7 +386,7 @@ def create_password_reset_token(user: User) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def send_password_reset_email(
|
def send_password_reset_email(
|
||||||
user: User, site: Union[Site, RequestSite], token: str
|
user: User, site: Site | RequestSite, token: str
|
||||||
) -> None:
|
) -> None:
|
||||||
reset_url = reverse("password_reset_confirm", kwargs={"token": token})
|
reset_url = reverse("password_reset_confirm", kwargs={"token": token})
|
||||||
context = {
|
context = {
|
||||||
@@ -363,6 +418,14 @@ def request_password_reset(request: HttpRequest) -> HttpResponse:
|
|||||||
token = create_password_reset_token(user)
|
token = create_password_reset_token(user)
|
||||||
site = get_current_site(request)
|
site = get_current_site(request)
|
||||||
send_password_reset_email(user, site, token)
|
send_password_reset_email(user, site, token)
|
||||||
|
log_security_event(
|
||||||
|
logger,
|
||||||
|
event_type="password_reset_requested",
|
||||||
|
message=f"Password reset requested for {email}",
|
||||||
|
severity="medium",
|
||||||
|
context={"email": email},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
messages.success(request, "Password reset email sent")
|
messages.success(request, "Password reset email sent")
|
||||||
return redirect("account_login")
|
return redirect("account_login")
|
||||||
@@ -373,7 +436,7 @@ def handle_password_reset(
|
|||||||
user: User,
|
user: User,
|
||||||
new_password: str,
|
new_password: str,
|
||||||
reset: PasswordReset,
|
reset: PasswordReset,
|
||||||
site: Union[Site, RequestSite],
|
site: Site | RequestSite,
|
||||||
) -> None:
|
) -> None:
|
||||||
user.set_password(new_password)
|
user.set_password(new_password)
|
||||||
user.save()
|
user.save()
|
||||||
@@ -381,12 +444,21 @@ def handle_password_reset(
|
|||||||
reset.used = True
|
reset.used = True
|
||||||
reset.save()
|
reset.save()
|
||||||
|
|
||||||
|
log_security_event(
|
||||||
|
logger,
|
||||||
|
event_type="password_reset_complete",
|
||||||
|
message=f"Password reset completed for user {user.username}",
|
||||||
|
severity="medium",
|
||||||
|
context={"user_id": user.id, "username": user.username},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
send_password_reset_confirmation(user, site)
|
send_password_reset_confirmation(user, site)
|
||||||
messages.success(request, "Password reset successfully")
|
messages.success(request, "Password reset successfully")
|
||||||
|
|
||||||
|
|
||||||
def send_password_reset_confirmation(
|
def send_password_reset_confirmation(
|
||||||
user: User, site: Union[Site, RequestSite]
|
user: User, site: Site | RequestSite
|
||||||
) -> None:
|
) -> None:
|
||||||
context = {
|
context = {
|
||||||
"user": user,
|
"user": user,
|
||||||
|
|||||||
@@ -14,32 +14,25 @@ Usage:
|
|||||||
import random
|
import random
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.gis.geos import Point
|
from django.contrib.gis.geos import Point
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
# Import all models
|
# Import all models
|
||||||
from apps.accounts.models import (
|
from apps.accounts.models import NotificationPreference, UserDeletionRequest, UserNotification, UserProfile
|
||||||
User, UserProfile, TopList, TopListItem, UserNotification,
|
|
||||||
NotificationPreference, UserDeletionRequest
|
|
||||||
)
|
|
||||||
from apps.parks.models import (
|
|
||||||
Park, ParkLocation, ParkArea, ParkPhoto, ParkReview
|
|
||||||
)
|
|
||||||
from apps.parks.models.companies import Company as ParkCompany, CompanyHeadquarters
|
|
||||||
from apps.rides.models import (
|
|
||||||
Ride, RideModel, RollerCoasterStats, RidePhoto, RideReview, RideLocation
|
|
||||||
)
|
|
||||||
from apps.rides.models.company import Company as RideCompany
|
|
||||||
from apps.core.history import HistoricalSlug
|
from apps.core.history import HistoricalSlug
|
||||||
|
from apps.parks.models import Park, ParkArea, ParkLocation, ParkPhoto, ParkReview
|
||||||
|
from apps.parks.models.companies import Company as ParkCompany
|
||||||
|
from apps.parks.models.companies import CompanyHeadquarters
|
||||||
|
from apps.rides.models import Ride, RideLocation, RideModel, RidePhoto, RideReview, RollerCoasterStats
|
||||||
|
from apps.rides.models.company import Company as RideCompany
|
||||||
|
|
||||||
# Try to import optional models that may not exist
|
# Try to import optional models that may not exist
|
||||||
try:
|
try:
|
||||||
from apps.rides.models import RideModelVariant, RideModelPhoto, RideModelTechnicalSpec
|
from apps.rides.models import RideModelPhoto, RideModelTechnicalSpec, RideModelVariant
|
||||||
except ImportError:
|
except ImportError:
|
||||||
RideModelVariant = None
|
RideModelVariant = None
|
||||||
RideModelPhoto = None
|
RideModelPhoto = None
|
||||||
@@ -51,7 +44,7 @@ except ImportError:
|
|||||||
RideRanking = None
|
RideRanking = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from apps.moderation.models import ModerationQueue, ModerationAction
|
from apps.moderation.models import ModerationAction, ModerationQueue
|
||||||
except ImportError:
|
except ImportError:
|
||||||
ModerationQueue = None
|
ModerationQueue = None
|
||||||
ModerationAction = None
|
ModerationAction = None
|
||||||
@@ -128,7 +121,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# Create content and interactions
|
# Create content and interactions
|
||||||
self.create_reviews(options['reviews'], users, parks, rides)
|
self.create_reviews(options['reviews'], users, parks, rides)
|
||||||
self.create_top_lists(users, parks, rides)
|
|
||||||
self.create_notifications(users)
|
self.create_notifications(users)
|
||||||
self.create_moderation_data(users, parks, rides)
|
self.create_moderation_data(users, parks, rides)
|
||||||
|
|
||||||
@@ -149,7 +142,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
models_to_clear = [
|
models_to_clear = [
|
||||||
# Content and interactions (clear first)
|
# Content and interactions (clear first)
|
||||||
TopListItem, TopList, UserNotification, NotificationPreference,
|
UserNotification, NotificationPreference,
|
||||||
ParkReview, RideReview, ModerationAction, ModerationQueue,
|
ParkReview, RideReview, ModerationAction, ModerationQueue,
|
||||||
|
|
||||||
# Media
|
# Media
|
||||||
@@ -193,7 +186,7 @@ class Command(BaseCommand):
|
|||||||
# Continue with other models
|
# Continue with other models
|
||||||
continue
|
continue
|
||||||
|
|
||||||
def create_users(self, count: int) -> List[User]:
|
def create_users(self, count: int) -> list[User]:
|
||||||
"""Create diverse users with comprehensive profiles"""
|
"""Create diverse users with comprehensive profiles"""
|
||||||
self.stdout.write(f'👥 Creating {count} users...')
|
self.stdout.write(f'👥 Creating {count} users...')
|
||||||
|
|
||||||
@@ -252,7 +245,7 @@ class Command(BaseCommand):
|
|||||||
domains = ['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'icloud.com']
|
domains = ['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'icloud.com']
|
||||||
|
|
||||||
# Create regular users
|
# Create regular users
|
||||||
for i in range(count - 2): # -2 for admin and moderator
|
for _i in range(count - 2): # -2 for admin and moderator
|
||||||
first_name = random.choice(first_names)
|
first_name = random.choice(first_names)
|
||||||
last_name = random.choice(last_names)
|
last_name = random.choice(last_names)
|
||||||
username = f"{first_name.lower()}{last_name.lower()}{random.randint(1, 999)}"
|
username = f"{first_name.lower()}{last_name.lower()}{random.randint(1, 999)}"
|
||||||
@@ -319,7 +312,7 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(f' ✅ Created {len(users)} users')
|
self.stdout.write(f' ✅ Created {len(users)} users')
|
||||||
return users
|
return users
|
||||||
|
|
||||||
def create_companies(self, count: int) -> List:
|
def create_companies(self, count: int) -> list:
|
||||||
"""Create companies with different roles"""
|
"""Create companies with different roles"""
|
||||||
self.stdout.write(f'🏢 Creating {count} companies...')
|
self.stdout.write(f'🏢 Creating {count} companies...')
|
||||||
|
|
||||||
@@ -414,7 +407,7 @@ class Command(BaseCommand):
|
|||||||
# Create additional random companies to reach the target count
|
# Create additional random companies to reach the target count
|
||||||
company_types = ['Theme Parks', 'Amusements', 'Entertainment', 'Rides', 'Design', 'Engineering']
|
company_types = ['Theme Parks', 'Amusements', 'Entertainment', 'Rides', 'Design', 'Engineering']
|
||||||
|
|
||||||
for i in range(len(all_company_data), count):
|
for _i in range(len(all_company_data), count):
|
||||||
company_type = random.choice(company_types)
|
company_type = random.choice(company_types)
|
||||||
name = f"{random.choice(['Global', 'International', 'Premier', 'Elite', 'Advanced', 'Creative'])} {company_type} {'Group' if random.random() < 0.5 else 'Corporation'}"
|
name = f"{random.choice(['Global', 'International', 'Premier', 'Elite', 'Advanced', 'Creative'])} {company_type} {'Group' if random.random() < 0.5 else 'Corporation'}"
|
||||||
|
|
||||||
@@ -457,7 +450,7 @@ class Command(BaseCommand):
|
|||||||
# Create headquarters
|
# Create headquarters
|
||||||
cities = ['Los Angeles', 'New York', 'Chicago', 'Houston', 'Phoenix', 'Philadelphia', 'San Antonio', 'San Diego', 'Dallas', 'San Jose']
|
cities = ['Los Angeles', 'New York', 'Chicago', 'Houston', 'Phoenix', 'Philadelphia', 'San Antonio', 'San Diego', 'Dallas', 'San Jose']
|
||||||
states = ['CA', 'NY', 'IL', 'TX', 'AZ', 'PA', 'TX', 'CA', 'TX', 'CA']
|
states = ['CA', 'NY', 'IL', 'TX', 'AZ', 'PA', 'TX', 'CA', 'TX', 'CA']
|
||||||
city_state = random.choice(list(zip(cities, states)))
|
city_state = random.choice(list(zip(cities, states, strict=False)))
|
||||||
|
|
||||||
CompanyHeadquarters.objects.create(
|
CompanyHeadquarters.objects.create(
|
||||||
company=company,
|
company=company,
|
||||||
@@ -473,7 +466,7 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(f' ✅ Created {len(companies)} companies')
|
self.stdout.write(f' ✅ Created {len(companies)} companies')
|
||||||
return companies
|
return companies
|
||||||
|
|
||||||
def create_ride_models(self, count: int, companies: List) -> List[RideModel]:
|
def create_ride_models(self, count: int, companies: list) -> list[RideModel]:
|
||||||
"""Create ride models from manufacturers"""
|
"""Create ride models from manufacturers"""
|
||||||
self.stdout.write(f'🎢 Creating {count} ride models...')
|
self.stdout.write(f'🎢 Creating {count} ride models...')
|
||||||
|
|
||||||
@@ -573,7 +566,7 @@ class Command(BaseCommand):
|
|||||||
model_types = ['Coaster', 'Ride', 'System', 'Experience', 'Adventure']
|
model_types = ['Coaster', 'Ride', 'System', 'Experience', 'Adventure']
|
||||||
prefixes = ['Mega', 'Super', 'Ultra', 'Hyper', 'Giga', 'Extreme', 'Family', 'Junior']
|
prefixes = ['Mega', 'Super', 'Ultra', 'Hyper', 'Giga', 'Extreme', 'Family', 'Junior']
|
||||||
|
|
||||||
for i in range(len(famous_models), count):
|
for _i in range(len(famous_models), count):
|
||||||
manufacturer = random.choice(manufacturers)
|
manufacturer = random.choice(manufacturers)
|
||||||
category = random.choice(['RC', 'DR', 'FR', 'WR', 'TR'])
|
category = random.choice(['RC', 'DR', 'FR', 'WR', 'TR'])
|
||||||
|
|
||||||
@@ -612,7 +605,7 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(f' ✅ Created {len(ride_models)} ride models')
|
self.stdout.write(f' ✅ Created {len(ride_models)} ride models')
|
||||||
return ride_models
|
return ride_models
|
||||||
|
|
||||||
def create_parks(self, count: int, companies: List) -> List[Park]:
|
def create_parks(self, count: int, companies: list) -> list[Park]:
|
||||||
"""Create parks with locations and areas"""
|
"""Create parks with locations and areas"""
|
||||||
self.stdout.write(f'🏰 Creating {count} parks...')
|
self.stdout.write(f'🏰 Creating {count} parks...')
|
||||||
|
|
||||||
@@ -805,7 +798,7 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(f' ✅ Created {len(parks)} parks')
|
self.stdout.write(f' ✅ Created {len(parks)} parks')
|
||||||
return parks
|
return parks
|
||||||
|
|
||||||
def create_rides(self, count: int, parks: List[Park], companies: List, ride_models: List[RideModel]) -> List[Ride]:
|
def create_rides(self, count: int, parks: list[Park], companies: list, ride_models: list[RideModel]) -> list[Ride]:
|
||||||
"""Create rides with comprehensive details"""
|
"""Create rides with comprehensive details"""
|
||||||
self.stdout.write(f'🎠 Creating {count} rides...')
|
self.stdout.write(f'🎠 Creating {count} rides...')
|
||||||
|
|
||||||
@@ -897,7 +890,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
categories = ['RC', 'DR', 'FR', 'WR', 'TR', 'OT']
|
categories = ['RC', 'DR', 'FR', 'WR', 'TR', 'OT']
|
||||||
|
|
||||||
for i in range(len(famous_coasters), count):
|
for _i in range(len(famous_coasters), count):
|
||||||
park = random.choice(parks)
|
park = random.choice(parks)
|
||||||
park_areas = list(park.areas.all())
|
park_areas = list(park.areas.all())
|
||||||
park_area = random.choice(park_areas) if park_areas else None
|
park_area = random.choice(park_areas) if park_areas else None
|
||||||
@@ -951,7 +944,7 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(f' ✅ Created {len(rides)} rides')
|
self.stdout.write(f' ✅ Created {len(rides)} rides')
|
||||||
return rides
|
return rides
|
||||||
|
|
||||||
def create_reviews(self, count: int, users: List[User], parks: List[Park], rides: List[Ride]) -> None:
|
def create_reviews(self, count: int, users: list[User], parks: list[Park], rides: list[Ride]) -> None:
|
||||||
"""Create park and ride reviews"""
|
"""Create park and ride reviews"""
|
||||||
self.stdout.write(f'📝 Creating {count} reviews...')
|
self.stdout.write(f'📝 Creating {count} reviews...')
|
||||||
|
|
||||||
@@ -1042,67 +1035,9 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
self.stdout.write(f' ✅ Created {count} reviews')
|
self.stdout.write(f' ✅ Created {count} reviews')
|
||||||
|
|
||||||
def create_top_lists(self, users: List[User], parks: List[Park], rides: List[Ride]) -> None:
|
|
||||||
"""Create user top lists"""
|
|
||||||
self.stdout.write('📋 Creating top lists...')
|
|
||||||
|
|
||||||
if not users:
|
|
||||||
self.stdout.write(' ⚠️ No users found, skipping top lists')
|
|
||||||
return
|
|
||||||
|
|
||||||
list_count = 0
|
def create_notifications(self, users: list[User]) -> None:
|
||||||
|
|
||||||
# Create top lists for some users
|
|
||||||
for user in random.sample(users, min(len(users), 10)):
|
|
||||||
# Create roller coaster top list
|
|
||||||
if rides:
|
|
||||||
coasters = [r for r in rides if r.category == 'RC']
|
|
||||||
if coasters:
|
|
||||||
top_list = TopList.objects.create(
|
|
||||||
user=user,
|
|
||||||
title=f"{user.get_display_name()}'s Top Roller Coasters",
|
|
||||||
category="RC",
|
|
||||||
description="My favorite roller coasters ranked by thrill and experience",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add items to the list
|
|
||||||
for rank, coaster in enumerate(random.sample(coasters, min(len(coasters), 10)), 1):
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
content_type = ContentType.objects.get_for_model(coaster)
|
|
||||||
TopListItem.objects.create(
|
|
||||||
top_list=top_list,
|
|
||||||
content_type=content_type,
|
|
||||||
object_id=coaster.pk,
|
|
||||||
rank=rank,
|
|
||||||
notes=f"Incredible {coaster.category} experience at {coaster.park.name}",
|
|
||||||
)
|
|
||||||
list_count += 1
|
|
||||||
|
|
||||||
# Create park top list
|
|
||||||
if parks and random.random() < 0.5:
|
|
||||||
top_list = TopList.objects.create(
|
|
||||||
user=user,
|
|
||||||
title=f"{user.get_display_name()}'s Favorite Parks",
|
|
||||||
category="PK",
|
|
||||||
description="Theme parks that provide the best overall experience",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add items to the list
|
|
||||||
for rank, park in enumerate(random.sample(parks, min(len(parks), 5)), 1):
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
content_type = ContentType.objects.get_for_model(park)
|
|
||||||
TopListItem.objects.create(
|
|
||||||
top_list=top_list,
|
|
||||||
content_type=content_type,
|
|
||||||
object_id=park.pk,
|
|
||||||
rank=rank,
|
|
||||||
notes=f"Amazing park with great {park.park_type.lower().replace('_', ' ')} atmosphere",
|
|
||||||
)
|
|
||||||
list_count += 1
|
|
||||||
|
|
||||||
self.stdout.write(f' ✅ Created {list_count} top lists')
|
|
||||||
|
|
||||||
def create_notifications(self, users: List[User]) -> None:
|
|
||||||
"""Create sample notifications for users"""
|
"""Create sample notifications for users"""
|
||||||
self.stdout.write('🔔 Creating notifications...')
|
self.stdout.write('🔔 Creating notifications...')
|
||||||
|
|
||||||
@@ -1138,7 +1073,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
self.stdout.write(f' ✅ Created {notification_count} notifications')
|
self.stdout.write(f' ✅ Created {notification_count} notifications')
|
||||||
|
|
||||||
def create_moderation_data(self, users: List[User], parks: List[Park], rides: List[Ride]) -> None:
|
def create_moderation_data(self, users: list[User], parks: list[Park], rides: list[Ride]) -> None:
|
||||||
"""Create moderation queue and actions"""
|
"""Create moderation queue and actions"""
|
||||||
self.stdout.write('🛡️ Creating moderation data...')
|
self.stdout.write('🛡️ Creating moderation data...')
|
||||||
|
|
||||||
@@ -1154,7 +1089,7 @@ class Command(BaseCommand):
|
|||||||
# Implementation depends on the actual moderation models structure
|
# Implementation depends on the actual moderation models structure
|
||||||
self.stdout.write(' ✅ Moderation data creation skipped (models not fully defined)')
|
self.stdout.write(' ✅ Moderation data creation skipped (models not fully defined)')
|
||||||
|
|
||||||
def create_photos(self, parks: List[Park], rides: List[Ride], ride_models: List[RideModel]) -> None:
|
def create_photos(self, parks: list[Park], rides: list[Ride], ride_models: list[RideModel]) -> None:
|
||||||
"""Create sample photo records"""
|
"""Create sample photo records"""
|
||||||
self.stdout.write('📸 Creating photo records...')
|
self.stdout.write('📸 Creating photo records...')
|
||||||
|
|
||||||
@@ -1167,7 +1102,7 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(' ⚠️ Photo creation skipped (requires actual CloudflareImage instances)')
|
self.stdout.write(' ⚠️ Photo creation skipped (requires actual CloudflareImage instances)')
|
||||||
self.stdout.write(' ℹ️ To create photos, you need to upload actual images to Cloudflare first')
|
self.stdout.write(' ℹ️ To create photos, you need to upload actual images to Cloudflare first')
|
||||||
|
|
||||||
def create_rankings(self, rides: List[Ride]) -> None:
|
def create_rankings(self, rides: list[Ride]) -> None:
|
||||||
"""Create ride rankings if model exists"""
|
"""Create ride rankings if model exists"""
|
||||||
self.stdout.write('🏆 Creating ride rankings...')
|
self.stdout.write('🏆 Creating ride rankings...')
|
||||||
|
|
||||||
@@ -1198,7 +1133,7 @@ class Command(BaseCommand):
|
|||||||
'Ride Models': RideModel.objects.count(),
|
'Ride Models': RideModel.objects.count(),
|
||||||
'Park Reviews': ParkReview.objects.count(),
|
'Park Reviews': ParkReview.objects.count(),
|
||||||
'Ride Reviews': RideReview.objects.count(),
|
'Ride Reviews': RideReview.objects.count(),
|
||||||
'Top Lists': TopList.objects.count(),
|
|
||||||
'Notifications': UserNotification.objects.count(),
|
'Notifications': UserNotification.objects.count(),
|
||||||
'Park Photos': ParkPhoto.objects.count(),
|
'Park Photos': ParkPhoto.objects.count(),
|
||||||
'Ride Photos': RidePhoto.objects.count(),
|
'Ride Photos': RidePhoto.objects.count(),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from django.urls import path, include
|
from django.urls import include, path
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("v1/", include("apps.api.v1.urls")),
|
path("v1/", include("apps.api.v1.urls")),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from rest_framework import serializers
|
|
||||||
from drf_spectacular.utils import extend_schema_field
|
from drf_spectacular.utils import extend_schema_field
|
||||||
from apps.accounts.models import UserProfile, TopList, TopListItem
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.accounts.models import UserProfile
|
||||||
from apps.accounts.serializers import UserSerializer # existing shared user serializer
|
from apps.accounts.serializers import UserSerializer # existing shared user serializer
|
||||||
|
|
||||||
|
|
||||||
@@ -11,10 +12,21 @@ class UserProfileCreateInputSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class UserProfileUpdateInputSerializer(serializers.ModelSerializer):
|
class UserProfileUpdateInputSerializer(serializers.ModelSerializer):
|
||||||
|
cloudflare_image_id = serializers.CharField(write_only=True, required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = UserProfile
|
model = UserProfile
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
extra_kwargs = {"user": {"read_only": True}}
|
extra_kwargs = {"user": {"read_only": True}, "avatar": {"read_only": True}}
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
cloudflare_id = validated_data.pop("cloudflare_image_id", None)
|
||||||
|
if cloudflare_id:
|
||||||
|
from django_cloudflareimages_toolkit.models import CloudflareImage
|
||||||
|
image, _ = CloudflareImage.objects.get_or_create(cloudflare_id=cloudflare_id)
|
||||||
|
instance.avatar = image
|
||||||
|
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
class UserProfileOutputSerializer(serializers.ModelSerializer):
|
class UserProfileOutputSerializer(serializers.ModelSerializer):
|
||||||
@@ -38,49 +50,3 @@ class UserProfileOutputSerializer(serializers.ModelSerializer):
|
|||||||
if avatar:
|
if avatar:
|
||||||
return getattr(avatar, "url", None)
|
return getattr(avatar, "url", None)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class TopListItemCreateInputSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = TopListItem
|
|
||||||
fields = "__all__"
|
|
||||||
|
|
||||||
|
|
||||||
class TopListItemUpdateInputSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = TopListItem
|
|
||||||
fields = "__all__"
|
|
||||||
# allow updates, adjust as needed
|
|
||||||
extra_kwargs = {"top_list": {"read_only": False}}
|
|
||||||
|
|
||||||
|
|
||||||
class TopListItemOutputSerializer(serializers.ModelSerializer):
|
|
||||||
# Remove the ride field since it doesn't exist on the model
|
|
||||||
# The model likely uses a generic foreign key or different field name
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = TopListItem
|
|
||||||
fields = "__all__"
|
|
||||||
|
|
||||||
|
|
||||||
class TopListCreateInputSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = TopList
|
|
||||||
fields = "__all__"
|
|
||||||
|
|
||||||
|
|
||||||
class TopListUpdateInputSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = TopList
|
|
||||||
fields = "__all__"
|
|
||||||
# user is set by view's perform_create
|
|
||||||
extra_kwargs = {"user": {"read_only": True}}
|
|
||||||
|
|
||||||
|
|
||||||
class TopListOutputSerializer(serializers.ModelSerializer):
|
|
||||||
user = UserSerializer(read_only=True)
|
|
||||||
items = TopListItemOutputSerializer(many=True, read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = TopList
|
|
||||||
fields = "__all__"
|
|
||||||
|
|||||||
@@ -2,8 +2,14 @@
|
|||||||
URL configuration for user account management API endpoints.
|
URL configuration for user account management API endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.urls import path
|
from django.urls import include, path
|
||||||
from . import views
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from . import views, views_credits, views_magic_link
|
||||||
|
|
||||||
|
# Register ViewSets
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"credits", views_credits.RideCreditViewSet, basename="ride-credit")
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Admin endpoints for user management
|
# Admin endpoints for user management
|
||||||
@@ -33,6 +39,8 @@ urlpatterns = [
|
|||||||
views.cancel_account_deletion,
|
views.cancel_account_deletion,
|
||||||
name="cancel_account_deletion",
|
name="cancel_account_deletion",
|
||||||
),
|
),
|
||||||
|
# Data Export endpoint
|
||||||
|
path("data-export/", views.export_user_data, name="export_user_data"),
|
||||||
# User profile endpoints
|
# User profile endpoints
|
||||||
path("profile/", views.get_user_profile, name="get_user_profile"),
|
path("profile/", views.get_user_profile, name="get_user_profile"),
|
||||||
path("profile/account/", views.update_user_account, name="update_user_account"),
|
path("profile/account/", views.update_user_account, name="update_user_account"),
|
||||||
@@ -106,4 +114,18 @@ urlpatterns = [
|
|||||||
path("profile/avatar/upload/", views.upload_avatar, name="upload_avatar"),
|
path("profile/avatar/upload/", views.upload_avatar, name="upload_avatar"),
|
||||||
path("profile/avatar/save/", views.save_avatar_image, name="save_avatar_image"),
|
path("profile/avatar/save/", views.save_avatar_image, name="save_avatar_image"),
|
||||||
path("profile/avatar/delete/", views.delete_avatar, name="delete_avatar"),
|
path("profile/avatar/delete/", views.delete_avatar, name="delete_avatar"),
|
||||||
|
|
||||||
|
# Login history endpoint
|
||||||
|
path("login-history/", views.get_login_history, name="get_login_history"),
|
||||||
|
|
||||||
|
# Magic Link (Login by Code) endpoints
|
||||||
|
path("magic-link/request/", views_magic_link.request_magic_link, name="request_magic_link"),
|
||||||
|
path("magic-link/verify/", views_magic_link.verify_magic_link, name="verify_magic_link"),
|
||||||
|
|
||||||
|
# Public Profile
|
||||||
|
path("profiles/<str:username>/", views.get_public_user_profile, name="get_public_user_profile"),
|
||||||
|
|
||||||
|
# ViewSet routes
|
||||||
|
path("", include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -6,41 +6,44 @@ user deletion while preserving submissions, profile management, settings,
|
|||||||
preferences, privacy, notifications, and security.
|
preferences, privacy, notifications, and security.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from apps.api.v1.serializers.accounts import (
|
|
||||||
CompleteUserSerializer,
|
|
||||||
UserPreferencesSerializer,
|
|
||||||
NotificationSettingsSerializer,
|
|
||||||
PrivacySettingsSerializer,
|
|
||||||
SecuritySettingsSerializer,
|
|
||||||
UserStatisticsSerializer,
|
|
||||||
TopListSerializer,
|
|
||||||
AccountUpdateSerializer,
|
|
||||||
ProfileUpdateSerializer,
|
|
||||||
ThemePreferenceSerializer,
|
|
||||||
UserNotificationSerializer,
|
|
||||||
NotificationPreferenceSerializer,
|
|
||||||
MarkNotificationsReadSerializer,
|
|
||||||
AvatarUploadSerializer,
|
|
||||||
)
|
|
||||||
from apps.accounts.services import UserDeletionService
|
|
||||||
from apps.accounts.models import (
|
|
||||||
User,
|
|
||||||
UserProfile,
|
|
||||||
TopList,
|
|
||||||
UserNotification,
|
|
||||||
NotificationPreference,
|
|
||||||
)
|
|
||||||
import logging
|
import logging
|
||||||
from rest_framework import status
|
|
||||||
from rest_framework.decorators import api_view, permission_classes
|
|
||||||
from rest_framework.permissions import IsAuthenticated, IsAdminUser
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
|
||||||
from drf_spectacular.types import OpenApiTypes
|
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from rest_framework.permissions import AllowAny
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django_cloudflareimages_toolkit.models import CloudflareImage
|
from django_cloudflareimages_toolkit.models import CloudflareImage
|
||||||
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from apps.accounts.export_service import UserExportService
|
||||||
|
from apps.accounts.models import (
|
||||||
|
NotificationPreference,
|
||||||
|
User,
|
||||||
|
UserNotification,
|
||||||
|
UserProfile,
|
||||||
|
)
|
||||||
|
from apps.accounts.services import UserDeletionService
|
||||||
|
from apps.api.v1.serializers.accounts import (
|
||||||
|
AccountUpdateSerializer,
|
||||||
|
AvatarUploadSerializer,
|
||||||
|
CompleteUserSerializer,
|
||||||
|
MarkNotificationsReadSerializer,
|
||||||
|
NotificationPreferenceSerializer,
|
||||||
|
NotificationSettingsSerializer,
|
||||||
|
PrivacySettingsSerializer,
|
||||||
|
ProfileUpdateSerializer,
|
||||||
|
PublicUserSerializer,
|
||||||
|
SecuritySettingsSerializer,
|
||||||
|
ThemePreferenceSerializer,
|
||||||
|
UserListSerializer,
|
||||||
|
UserNotificationSerializer,
|
||||||
|
UserPreferencesSerializer,
|
||||||
|
UserStatisticsSerializer,
|
||||||
|
)
|
||||||
|
from apps.lists.models import UserList
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -831,7 +834,7 @@ def check_user_deletion_eligibility(request, user_id):
|
|||||||
user, "uploaded_ride_photos", user.__class__.objects.none()
|
user, "uploaded_ride_photos", user.__class__.objects.none()
|
||||||
).count(),
|
).count(),
|
||||||
"top_lists": getattr(
|
"top_lists": getattr(
|
||||||
user, "top_lists", user.__class__.objects.none()
|
user, "user_lists", user.__class__.objects.none()
|
||||||
).count(),
|
).count(),
|
||||||
"edit_submissions": getattr(
|
"edit_submissions": getattr(
|
||||||
user, "edit_submissions", user.__class__.objects.none()
|
user, "edit_submissions", user.__class__.objects.none()
|
||||||
@@ -1271,10 +1274,10 @@ def update_security_settings(request):
|
|||||||
|
|
||||||
# Handle security settings updates
|
# Handle security settings updates
|
||||||
if "two_factor_enabled" in request.data:
|
if "two_factor_enabled" in request.data:
|
||||||
setattr(user, "two_factor_enabled", request.data["two_factor_enabled"])
|
user.two_factor_enabled = request.data["two_factor_enabled"]
|
||||||
|
|
||||||
if "login_notifications" in request.data:
|
if "login_notifications" in request.data:
|
||||||
setattr(user, "login_notifications", request.data["login_notifications"])
|
user.login_notifications = request.data["login_notifications"]
|
||||||
|
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
@@ -1302,12 +1305,23 @@ def get_user_statistics(request):
|
|||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
# Calculate user statistics
|
# Calculate user statistics
|
||||||
|
# See FUTURE_WORK.md - THRILLWIKI-104 for full statistics tracking implementation
|
||||||
|
from apps.parks.models import ParkReview
|
||||||
|
from apps.parks.models.media import ParkPhoto
|
||||||
|
from apps.rides.models import RideReview
|
||||||
|
from apps.rides.models.media import RidePhoto
|
||||||
|
|
||||||
|
# Count photos uploaded by user
|
||||||
|
park_photos_count = ParkPhoto.objects.filter(uploaded_by=user).count()
|
||||||
|
ride_photos_count = RidePhoto.objects.filter(uploaded_by=user).count()
|
||||||
|
total_photos_uploaded = park_photos_count + ride_photos_count
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"parks_visited": 0, # TODO: Implement based on reviews/check-ins
|
"parks_visited": ParkReview.objects.filter(user=user).values("park").distinct().count(),
|
||||||
"rides_ridden": 0, # TODO: Implement based on reviews/check-ins
|
"rides_ridden": RideReview.objects.filter(user=user).values("ride").distinct().count(),
|
||||||
"reviews_written": 0, # TODO: Count user's reviews
|
"reviews_written": ParkReview.objects.filter(user=user).count() + RideReview.objects.filter(user=user).count(),
|
||||||
"photos_uploaded": 0, # TODO: Count user's photos
|
"photos_uploaded": total_photos_uploaded,
|
||||||
"top_lists_created": TopList.objects.filter(user=user).count(),
|
"top_lists_created": UserList.objects.filter(user=user).count(),
|
||||||
"member_since": user.date_joined,
|
"member_since": user.date_joined,
|
||||||
"last_activity": user.last_login,
|
"last_activity": user.last_login,
|
||||||
}
|
}
|
||||||
@@ -1324,7 +1338,7 @@ def get_user_statistics(request):
|
|||||||
summary="Get user's top lists",
|
summary="Get user's top lists",
|
||||||
description="Get all top lists created by the authenticated user.",
|
description="Get all top lists created by the authenticated user.",
|
||||||
responses={
|
responses={
|
||||||
200: TopListSerializer(many=True),
|
200: UserListSerializer(many=True),
|
||||||
401: {"description": "Authentication required"},
|
401: {"description": "Authentication required"},
|
||||||
},
|
},
|
||||||
tags=["User Content"],
|
tags=["User Content"],
|
||||||
@@ -1333,8 +1347,8 @@ def get_user_statistics(request):
|
|||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def get_user_top_lists(request):
|
def get_user_top_lists(request):
|
||||||
"""Get user's top lists."""
|
"""Get user's top lists."""
|
||||||
top_lists = TopList.objects.filter(user=request.user).order_by("-created_at")
|
top_lists = UserList.objects.filter(user=request.user).order_by("-created_at")
|
||||||
serializer = TopListSerializer(top_lists, many=True)
|
serializer = UserListSerializer(top_lists, many=True)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
@@ -1342,9 +1356,9 @@ def get_user_top_lists(request):
|
|||||||
operation_id="create_top_list",
|
operation_id="create_top_list",
|
||||||
summary="Create a new top list",
|
summary="Create a new top list",
|
||||||
description="Create a new top list for the authenticated user.",
|
description="Create a new top list for the authenticated user.",
|
||||||
request=TopListSerializer,
|
request=UserListSerializer,
|
||||||
responses={
|
responses={
|
||||||
201: TopListSerializer,
|
201: UserListSerializer,
|
||||||
400: {"description": "Validation error"},
|
400: {"description": "Validation error"},
|
||||||
},
|
},
|
||||||
tags=["User Content"],
|
tags=["User Content"],
|
||||||
@@ -1353,7 +1367,7 @@ def get_user_top_lists(request):
|
|||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def create_top_list(request):
|
def create_top_list(request):
|
||||||
"""Create a new top list."""
|
"""Create a new top list."""
|
||||||
serializer = TopListSerializer(data=request.data, context={"request": request})
|
serializer = UserListSerializer(data=request.data, context={"request": request})
|
||||||
|
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
serializer.save(user=request.user)
|
serializer.save(user=request.user)
|
||||||
@@ -1366,9 +1380,9 @@ def create_top_list(request):
|
|||||||
operation_id="update_top_list",
|
operation_id="update_top_list",
|
||||||
summary="Update a top list",
|
summary="Update a top list",
|
||||||
description="Update a top list owned by the authenticated user.",
|
description="Update a top list owned by the authenticated user.",
|
||||||
request=TopListSerializer,
|
request=UserListSerializer,
|
||||||
responses={
|
responses={
|
||||||
200: TopListSerializer,
|
200: UserListSerializer,
|
||||||
400: {"description": "Validation error"},
|
400: {"description": "Validation error"},
|
||||||
404: {"description": "Top list not found"},
|
404: {"description": "Top list not found"},
|
||||||
},
|
},
|
||||||
@@ -1379,14 +1393,14 @@ def create_top_list(request):
|
|||||||
def update_top_list(request, list_id):
|
def update_top_list(request, list_id):
|
||||||
"""Update a top list."""
|
"""Update a top list."""
|
||||||
try:
|
try:
|
||||||
top_list = TopList.objects.get(id=list_id, user=request.user)
|
top_list = UserList.objects.get(id=list_id, user=request.user)
|
||||||
except TopList.DoesNotExist:
|
except UserList.DoesNotExist:
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Top list not found"},
|
{"error": "Top list not found"},
|
||||||
status=status.HTTP_404_NOT_FOUND
|
status=status.HTTP_404_NOT_FOUND
|
||||||
)
|
)
|
||||||
|
|
||||||
serializer = TopListSerializer(
|
serializer = UserListSerializer(
|
||||||
top_list, data=request.data, partial=True, context={"request": request}
|
top_list, data=request.data, partial=True, context={"request": request}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1412,10 +1426,10 @@ def update_top_list(request, list_id):
|
|||||||
def delete_top_list(request, list_id):
|
def delete_top_list(request, list_id):
|
||||||
"""Delete a top list."""
|
"""Delete a top list."""
|
||||||
try:
|
try:
|
||||||
top_list = TopList.objects.get(id=list_id, user=request.user)
|
top_list = UserList.objects.get(id=list_id, user=request.user)
|
||||||
top_list.delete()
|
top_list.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
except TopList.DoesNotExist:
|
except UserList.DoesNotExist:
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Top list not found"},
|
{"error": "Top list not found"},
|
||||||
status=status.HTTP_404_NOT_FOUND
|
status=status.HTTP_404_NOT_FOUND
|
||||||
@@ -1572,54 +1586,124 @@ def upload_avatar(request):
|
|||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
# === MISSING FUNCTION IMPLEMENTATIONS ===
|
|
||||||
|
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
operation_id="request_account_deletion",
|
operation_id="export_user_data",
|
||||||
summary="Request account deletion",
|
summary="Export all user data",
|
||||||
description="Request deletion of the authenticated user's account.",
|
description="Generate a JSON dump of all user data including profile, reviews, and lists.",
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "Deletion request created"},
|
200: {
|
||||||
400: {"description": "Cannot delete account"},
|
"description": "User data export",
|
||||||
|
"example": {
|
||||||
|
"account": {"username": "user", "email": "user@example.com"},
|
||||||
|
"profile": {"display_name": "User"},
|
||||||
|
"content": {"park_reviews": [], "lists": []}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
401: {"description": "Authentication required"},
|
||||||
},
|
},
|
||||||
tags=["Self-Service Account Management"],
|
tags=["Self-Service Account Management"],
|
||||||
)
|
)
|
||||||
@api_view(["POST"])
|
@api_view(["GET"])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def request_account_deletion(request):
|
def export_user_data(request):
|
||||||
"""Request account deletion."""
|
"""Export all user data as JSON."""
|
||||||
try:
|
try:
|
||||||
user = request.user
|
export_data = UserExportService.export_user_data(request.user)
|
||||||
|
return Response(export_data, status=status.HTTP_200_OK)
|
||||||
# Check if user can be deleted
|
|
||||||
can_delete, reason = UserDeletionService.can_delete_user(user)
|
|
||||||
if not can_delete:
|
|
||||||
return Response(
|
|
||||||
{"success": False, "error": reason},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create deletion request
|
|
||||||
deletion_request = UserDeletionService.create_deletion_request(user)
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
"success": True,
|
|
||||||
"message": "Verification code sent to your email",
|
|
||||||
"expires_at": deletion_request.expires_at,
|
|
||||||
"email": user.email,
|
|
||||||
},
|
|
||||||
status=status.HTTP_200_OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
return Response(
|
|
||||||
{"success": False, "error": str(e)},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.error(f"Error exporting data for user {request.user.id}: {e}", exc_info=True)
|
||||||
return Response(
|
return Response(
|
||||||
{"success": False, "error": f"Error creating deletion request: {str(e)}"},
|
{"error": "Failed to generate data export"},
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
operation_id="get_public_user_profile",
|
||||||
|
summary="Get public user profile",
|
||||||
|
description="Get the public profile of a user by username.",
|
||||||
|
responses={
|
||||||
|
200: PublicUserSerializer,
|
||||||
|
404: {"description": "User not found"},
|
||||||
|
},
|
||||||
|
tags=["User Profile"],
|
||||||
|
)
|
||||||
|
@api_view(["GET"])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def get_public_user_profile(request, username):
|
||||||
|
"""Get public user profile by username."""
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
serializer = PublicUserSerializer(user)
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
operation_id="get_login_history",
|
||||||
|
summary="Get user login history",
|
||||||
|
description=(
|
||||||
|
"Returns the authenticated user's recent login history including "
|
||||||
|
"IP addresses, devices, and timestamps for security auditing."
|
||||||
|
),
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name="limit",
|
||||||
|
type=OpenApiTypes.INT,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
description="Maximum number of entries to return (default: 20, max: 100)",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "Login history entries",
|
||||||
|
"example": {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"ip_address": "192.168.1.1",
|
||||||
|
"user_agent": "Mozilla/5.0...",
|
||||||
|
"login_method": "PASSWORD",
|
||||||
|
"login_method_display": "Password",
|
||||||
|
"login_timestamp": "2024-12-27T10:30:00Z",
|
||||||
|
"country": "United States",
|
||||||
|
"city": "New York",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {"description": "Authentication required"},
|
||||||
|
},
|
||||||
|
tags=["User Security"],
|
||||||
|
)
|
||||||
|
@api_view(["GET"])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_login_history(request):
|
||||||
|
"""Get user login history for security auditing."""
|
||||||
|
from apps.accounts.login_history import LoginHistory
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
limit = min(int(request.query_params.get("limit", 20)), 100)
|
||||||
|
|
||||||
|
# Get login history for user
|
||||||
|
entries = LoginHistory.objects.filter(user=user).order_by("-login_timestamp")[:limit]
|
||||||
|
|
||||||
|
# Serialize
|
||||||
|
results = []
|
||||||
|
for entry in entries:
|
||||||
|
results.append({
|
||||||
|
"id": entry.id,
|
||||||
|
"ip_address": entry.ip_address,
|
||||||
|
"user_agent": entry.user_agent[:100] if entry.user_agent else None, # Truncate long user agents
|
||||||
|
"login_method": entry.login_method,
|
||||||
|
"login_method_display": dict(LoginHistory._meta.get_field('login_method').choices).get(entry.login_method, entry.login_method),
|
||||||
|
"login_timestamp": entry.login_timestamp.isoformat(),
|
||||||
|
"country": entry.country,
|
||||||
|
"city": entry.city,
|
||||||
|
"success": entry.success,
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
"results": results,
|
||||||
|
"count": len(results),
|
||||||
|
})
|
||||||
|
|
||||||
|
|||||||
116
backend/apps/api/v1/accounts/views_credits.py
Normal file
116
backend/apps/api/v1/accounts/views_credits.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
from django.db import transaction
|
||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
|
from rest_framework import filters, permissions, status, viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from apps.api.v1.serializers.ride_credits import RideCreditSerializer
|
||||||
|
from apps.rides.models.credits import RideCredit
|
||||||
|
|
||||||
|
|
||||||
|
class RideCreditViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for managing Ride Credits.
|
||||||
|
Allows users to track rides they have ridden.
|
||||||
|
"""
|
||||||
|
serializer_class = RideCreditSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||||
|
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
|
||||||
|
filterset_fields = ['user__username', 'ride__park__slug', 'ride__manufacturer__slug']
|
||||||
|
ordering_fields = ['first_ridden_at', 'last_ridden_at', 'created_at', 'count', 'rating', 'display_order']
|
||||||
|
ordering = ['display_order', '-last_ridden_at']
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""
|
||||||
|
Return ride credits.
|
||||||
|
Optionally filter by user via query param ?user=username
|
||||||
|
"""
|
||||||
|
queryset = RideCredit.objects.all().select_related('ride', 'ride__park', 'user')
|
||||||
|
|
||||||
|
# Filter by user if provided
|
||||||
|
username = self.request.query_params.get('user')
|
||||||
|
if username:
|
||||||
|
queryset = queryset.filter(user__username=username)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
"""Associate the current user with the ride credit."""
|
||||||
|
serializer.save(user=self.request.user)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||||
|
@extend_schema(
|
||||||
|
summary="Reorder ride credits",
|
||||||
|
description="Bulk update the display order of ride credits. Send a list of {id, order} objects.",
|
||||||
|
request={
|
||||||
|
'application/json': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'order': {
|
||||||
|
'type': 'array',
|
||||||
|
'items': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'id': {'type': 'integer'},
|
||||||
|
'order': {'type': 'integer'}
|
||||||
|
},
|
||||||
|
'required': ['id', 'order']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def reorder(self, request):
|
||||||
|
"""
|
||||||
|
Bulk update display_order for multiple credits.
|
||||||
|
Expects: {"order": [{"id": 1, "order": 0}, {"id": 2, "order": 1}, ...]}
|
||||||
|
"""
|
||||||
|
order_data = request.data.get('order', [])
|
||||||
|
|
||||||
|
if not order_data:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No order data provided'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate that all credits belong to the current user
|
||||||
|
credit_ids = [item['id'] for item in order_data]
|
||||||
|
user_credits = RideCredit.objects.filter(
|
||||||
|
id__in=credit_ids,
|
||||||
|
user=request.user
|
||||||
|
).values_list('id', flat=True)
|
||||||
|
|
||||||
|
if set(credit_ids) != set(user_credits):
|
||||||
|
return Response(
|
||||||
|
{'error': 'You can only reorder your own credits'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bulk update in a transaction
|
||||||
|
with transaction.atomic():
|
||||||
|
for item in order_data:
|
||||||
|
RideCredit.objects.filter(
|
||||||
|
id=item['id'],
|
||||||
|
user=request.user
|
||||||
|
).update(display_order=item['order'])
|
||||||
|
|
||||||
|
return Response({'status': 'reordered', 'count': len(order_data)})
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary="List ride credits",
|
||||||
|
description="List ride credits. filter by user username.",
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name="user",
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
type=OpenApiTypes.STR,
|
||||||
|
description="Filter by username",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
def list(self, request, *args, **kwargs):
|
||||||
|
return super().list(request, *args, **kwargs)
|
||||||
|
|
||||||
180
backend/apps/api/v1/accounts/views_magic_link.py
Normal file
180
backend/apps/api/v1/accounts/views_magic_link.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"""
|
||||||
|
Magic Link (Login by Code) API views.
|
||||||
|
|
||||||
|
Provides API endpoints for passwordless login via email code.
|
||||||
|
Uses django-allauth's built-in login-by-code functionality.
|
||||||
|
"""
|
||||||
|
from django.conf import settings
|
||||||
|
from drf_spectacular.utils import OpenApiExample, extend_schema
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
try:
|
||||||
|
from allauth.account.internal.flows.login_by_code import perform_login_by_code, request_login_code
|
||||||
|
from allauth.account.models import EmailAddress
|
||||||
|
from allauth.account.utils import user_email # noqa: F401 - imported to verify availability
|
||||||
|
HAS_LOGIN_BY_CODE = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_LOGIN_BY_CODE = False
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary="Request magic link login code",
|
||||||
|
description="Send a one-time login code to the user's email address.",
|
||||||
|
request={
|
||||||
|
'application/json': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'email': {'type': 'string', 'format': 'email'}
|
||||||
|
},
|
||||||
|
'required': ['email']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses={
|
||||||
|
200: {'description': 'Login code sent successfully'},
|
||||||
|
400: {'description': 'Invalid email or feature disabled'},
|
||||||
|
},
|
||||||
|
examples=[
|
||||||
|
OpenApiExample(
|
||||||
|
'Request login code',
|
||||||
|
value={'email': 'user@example.com'},
|
||||||
|
request_only=True
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def request_magic_link(request):
|
||||||
|
"""
|
||||||
|
Request a login code to be sent to the user's email.
|
||||||
|
|
||||||
|
This is the first step of the magic link flow:
|
||||||
|
1. User enters their email
|
||||||
|
2. If the email exists, a code is sent
|
||||||
|
3. User enters the code to complete login
|
||||||
|
"""
|
||||||
|
if not getattr(settings, 'ACCOUNT_LOGIN_BY_CODE_ENABLED', False):
|
||||||
|
return Response(
|
||||||
|
{'error': 'Magic link login is not enabled'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
if not HAS_LOGIN_BY_CODE:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Login by code is not available in this version of allauth'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
email = request.data.get('email', '').lower().strip()
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Email is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if email exists (don't reveal if it doesn't for security)
|
||||||
|
try:
|
||||||
|
email_address = EmailAddress.objects.get(email__iexact=email, verified=True)
|
||||||
|
user = email_address.user
|
||||||
|
|
||||||
|
# Request the login code
|
||||||
|
request_login_code(request._request, user)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'message': 'If an account exists with this email, a login code has been sent.',
|
||||||
|
'timeout': getattr(settings, 'ACCOUNT_LOGIN_BY_CODE_TIMEOUT', 300)
|
||||||
|
})
|
||||||
|
|
||||||
|
except EmailAddress.DoesNotExist:
|
||||||
|
# Don't reveal that the email doesn't exist
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'message': 'If an account exists with this email, a login code has been sent.',
|
||||||
|
'timeout': getattr(settings, 'ACCOUNT_LOGIN_BY_CODE_TIMEOUT', 300)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary="Verify magic link code",
|
||||||
|
description="Verify the login code and complete the login process.",
|
||||||
|
request={
|
||||||
|
'application/json': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'email': {'type': 'string', 'format': 'email'},
|
||||||
|
'code': {'type': 'string'}
|
||||||
|
},
|
||||||
|
'required': ['email', 'code']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses={
|
||||||
|
200: {'description': 'Login successful'},
|
||||||
|
400: {'description': 'Invalid or expired code'},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def verify_magic_link(request):
|
||||||
|
"""
|
||||||
|
Verify the login code and complete the login.
|
||||||
|
|
||||||
|
This is the second step of the magic link flow.
|
||||||
|
"""
|
||||||
|
if not getattr(settings, 'ACCOUNT_LOGIN_BY_CODE_ENABLED', False):
|
||||||
|
return Response(
|
||||||
|
{'error': 'Magic link login is not enabled'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
if not HAS_LOGIN_BY_CODE:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Login by code is not available'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
email = request.data.get('email', '').lower().strip()
|
||||||
|
code = request.data.get('code', '').strip()
|
||||||
|
|
||||||
|
if not email or not code:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Email and code are required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
email_address = EmailAddress.objects.get(email__iexact=email, verified=True)
|
||||||
|
user = email_address.user
|
||||||
|
|
||||||
|
# Attempt to verify the code and log in
|
||||||
|
success = perform_login_by_code(request._request, user, code)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Login successful',
|
||||||
|
'user': {
|
||||||
|
'id': user.id,
|
||||||
|
'username': user.username,
|
||||||
|
'email': user.email
|
||||||
|
}
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Invalid or expired code. Please request a new one.'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
except EmailAddress.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Invalid email or code'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Invalid or expired code. Please request a new one.'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
385
backend/apps/api/v1/auth/mfa.py
Normal file
385
backend/apps/api/v1/auth/mfa.py
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
"""
|
||||||
|
MFA (Multi-Factor Authentication) API Views
|
||||||
|
|
||||||
|
Provides REST API endpoints for MFA operations using django-allauth's mfa module.
|
||||||
|
Supports TOTP (Time-based One-Time Password) authentication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from drf_spectacular.utils import extend_schema
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
try:
|
||||||
|
import qrcode
|
||||||
|
HAS_QRCODE = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_QRCODE = False
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
operation_id="get_mfa_status",
|
||||||
|
summary="Get MFA status for current user",
|
||||||
|
description="Returns whether MFA is enabled and what methods are configured.",
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "MFA status",
|
||||||
|
"example": {
|
||||||
|
"mfa_enabled": True,
|
||||||
|
"totp_enabled": True,
|
||||||
|
"recovery_codes_count": 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags=["MFA"],
|
||||||
|
)
|
||||||
|
@api_view(["GET"])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_mfa_status(request):
|
||||||
|
"""Get MFA status for current user."""
|
||||||
|
from allauth.mfa.models import Authenticator
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
authenticators = Authenticator.objects.filter(user=user)
|
||||||
|
|
||||||
|
totp_enabled = authenticators.filter(type=Authenticator.Type.TOTP).exists()
|
||||||
|
recovery_enabled = authenticators.filter(type=Authenticator.Type.RECOVERY_CODES).exists()
|
||||||
|
|
||||||
|
# Count recovery codes if any
|
||||||
|
recovery_count = 0
|
||||||
|
if recovery_enabled:
|
||||||
|
try:
|
||||||
|
recovery_auth = authenticators.get(type=Authenticator.Type.RECOVERY_CODES)
|
||||||
|
recovery_count = len(recovery_auth.data.get("codes", []))
|
||||||
|
except Authenticator.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
"mfa_enabled": totp_enabled,
|
||||||
|
"totp_enabled": totp_enabled,
|
||||||
|
"recovery_codes_enabled": recovery_enabled,
|
||||||
|
"recovery_codes_count": recovery_count,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
operation_id="setup_totp",
|
||||||
|
summary="Initialize TOTP setup",
|
||||||
|
description="Generates a new TOTP secret and returns the QR code for scanning.",
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "TOTP setup data",
|
||||||
|
"example": {
|
||||||
|
"secret": "ABCDEFGHIJKLMNOP",
|
||||||
|
"provisioning_uri": "otpauth://totp/ThrillWiki:user@example.com?secret=...",
|
||||||
|
"qr_code_base64": "data:image/png;base64,...",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags=["MFA"],
|
||||||
|
)
|
||||||
|
@api_view(["POST"])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def setup_totp(request):
|
||||||
|
"""Generate TOTP secret and QR code for setup."""
|
||||||
|
from allauth.mfa.totp.internal import auth as totp_auth
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
# Generate TOTP secret
|
||||||
|
secret = totp_auth.get_totp_secret(None) # Generate new secret
|
||||||
|
|
||||||
|
# Build provisioning URI
|
||||||
|
issuer = getattr(settings, "MFA_TOTP_ISSUER", "ThrillWiki")
|
||||||
|
account_name = user.email or user.username
|
||||||
|
uri = f"otpauth://totp/{issuer}:{account_name}?secret={secret}&issuer={issuer}"
|
||||||
|
|
||||||
|
# Generate QR code if qrcode library is available
|
||||||
|
qr_code_base64 = None
|
||||||
|
if HAS_QRCODE:
|
||||||
|
qr = qrcode.make(uri)
|
||||||
|
buffer = BytesIO()
|
||||||
|
qr.save(buffer, format="PNG")
|
||||||
|
qr_code_base64 = f"data:image/png;base64,{base64.b64encode(buffer.getvalue()).decode()}"
|
||||||
|
|
||||||
|
# Store secret in session for later verification
|
||||||
|
request.session["pending_totp_secret"] = secret
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
"secret": secret,
|
||||||
|
"provisioning_uri": uri,
|
||||||
|
"qr_code_base64": qr_code_base64,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
operation_id="activate_totp",
|
||||||
|
summary="Activate TOTP with verification code",
|
||||||
|
description="Verifies the TOTP code and activates 2FA for the user.",
|
||||||
|
request={
|
||||||
|
"application/json": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "6-digit TOTP code from authenticator app",
|
||||||
|
"example": "123456",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["code"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "TOTP activated successfully",
|
||||||
|
"example": {
|
||||||
|
"success": True,
|
||||||
|
"message": "Two-factor authentication enabled",
|
||||||
|
"recovery_codes": ["ABCD1234", "EFGH5678"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: {"description": "Invalid code or missing setup data"},
|
||||||
|
},
|
||||||
|
tags=["MFA"],
|
||||||
|
)
|
||||||
|
@api_view(["POST"])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def activate_totp(request):
|
||||||
|
"""Verify TOTP code and activate MFA."""
|
||||||
|
from allauth.mfa.models import Authenticator
|
||||||
|
from allauth.mfa.recovery_codes.internal import auth as recovery_auth
|
||||||
|
from allauth.mfa.totp.internal import auth as totp_auth
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
code = request.data.get("code", "").strip()
|
||||||
|
|
||||||
|
if not code:
|
||||||
|
return Response(
|
||||||
|
{"success": False, "error": "Verification code is required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get pending secret from session
|
||||||
|
secret = request.session.get("pending_totp_secret")
|
||||||
|
if not secret:
|
||||||
|
return Response(
|
||||||
|
{"success": False, "error": "No pending TOTP setup. Please start setup again."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the code
|
||||||
|
if not totp_auth.validate_totp_code(secret, code):
|
||||||
|
return Response(
|
||||||
|
{"success": False, "error": "Invalid verification code"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if already has TOTP
|
||||||
|
if Authenticator.objects.filter(user=user, type=Authenticator.Type.TOTP).exists():
|
||||||
|
return Response(
|
||||||
|
{"success": False, "error": "TOTP is already enabled"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create TOTP authenticator
|
||||||
|
Authenticator.objects.create(
|
||||||
|
user=user,
|
||||||
|
type=Authenticator.Type.TOTP,
|
||||||
|
data={"secret": secret},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate recovery codes
|
||||||
|
codes = recovery_auth.generate_recovery_codes()
|
||||||
|
Authenticator.objects.create(
|
||||||
|
user=user,
|
||||||
|
type=Authenticator.Type.RECOVERY_CODES,
|
||||||
|
data={"codes": codes},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clear session
|
||||||
|
del request.session["pending_totp_secret"]
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
"success": True,
|
||||||
|
"message": "Two-factor authentication enabled",
|
||||||
|
"recovery_codes": codes,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
operation_id="deactivate_totp",
|
||||||
|
summary="Disable TOTP authentication",
|
||||||
|
description="Removes TOTP from the user's account after password verification.",
|
||||||
|
request={
|
||||||
|
"application/json": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"password": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Current password for confirmation",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["password"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "TOTP disabled",
|
||||||
|
"example": {"success": True, "message": "Two-factor authentication disabled"},
|
||||||
|
},
|
||||||
|
400: {"description": "Invalid password or MFA not enabled"},
|
||||||
|
},
|
||||||
|
tags=["MFA"],
|
||||||
|
)
|
||||||
|
@api_view(["POST"])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def deactivate_totp(request):
|
||||||
|
"""Disable TOTP authentication."""
|
||||||
|
from allauth.mfa.models import Authenticator
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
password = request.data.get("password", "")
|
||||||
|
|
||||||
|
# Verify password
|
||||||
|
if not user.check_password(password):
|
||||||
|
return Response(
|
||||||
|
{"success": False, "error": "Invalid password"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove TOTP and recovery codes
|
||||||
|
deleted_count, _ = Authenticator.objects.filter(
|
||||||
|
user=user,
|
||||||
|
type__in=[Authenticator.Type.TOTP, Authenticator.Type.RECOVERY_CODES]
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
if deleted_count == 0:
|
||||||
|
return Response(
|
||||||
|
{"success": False, "error": "Two-factor authentication is not enabled"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
"success": True,
|
||||||
|
"message": "Two-factor authentication disabled",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
operation_id="verify_totp",
|
||||||
|
summary="Verify TOTP code during login",
|
||||||
|
description="Verifies the TOTP code as part of the login process.",
|
||||||
|
request={
|
||||||
|
"application/json": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"code": {"type": "string", "description": "6-digit TOTP code"}
|
||||||
|
},
|
||||||
|
"required": ["code"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses={
|
||||||
|
200: {"description": "Code verified", "example": {"success": True}},
|
||||||
|
400: {"description": "Invalid code"},
|
||||||
|
},
|
||||||
|
tags=["MFA"],
|
||||||
|
)
|
||||||
|
@api_view(["POST"])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def verify_totp(request):
|
||||||
|
"""Verify TOTP code."""
|
||||||
|
from allauth.mfa.models import Authenticator
|
||||||
|
from allauth.mfa.totp.internal import auth as totp_auth
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
code = request.data.get("code", "").strip()
|
||||||
|
|
||||||
|
if not code:
|
||||||
|
return Response(
|
||||||
|
{"success": False, "error": "Verification code is required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
authenticator = Authenticator.objects.get(user=user, type=Authenticator.Type.TOTP)
|
||||||
|
secret = authenticator.data.get("secret")
|
||||||
|
|
||||||
|
if totp_auth.validate_totp_code(secret, code):
|
||||||
|
return Response({"success": True})
|
||||||
|
else:
|
||||||
|
return Response(
|
||||||
|
{"success": False, "error": "Invalid verification code"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
except Authenticator.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"success": False, "error": "TOTP is not enabled"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
operation_id="regenerate_recovery_codes",
|
||||||
|
summary="Regenerate recovery codes",
|
||||||
|
description="Generates new recovery codes (invalidates old ones).",
|
||||||
|
request={
|
||||||
|
"application/json": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"password": {"type": "string", "description": "Current password"}
|
||||||
|
},
|
||||||
|
"required": ["password"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "New recovery codes",
|
||||||
|
"example": {"success": True, "recovery_codes": ["ABCD1234", "EFGH5678"]},
|
||||||
|
},
|
||||||
|
400: {"description": "Invalid password or MFA not enabled"},
|
||||||
|
},
|
||||||
|
tags=["MFA"],
|
||||||
|
)
|
||||||
|
@api_view(["POST"])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def regenerate_recovery_codes(request):
|
||||||
|
"""Regenerate recovery codes."""
|
||||||
|
from allauth.mfa.models import Authenticator
|
||||||
|
from allauth.mfa.recovery_codes.internal import auth as recovery_auth
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
password = request.data.get("password", "")
|
||||||
|
|
||||||
|
# Verify password
|
||||||
|
if not user.check_password(password):
|
||||||
|
return Response(
|
||||||
|
{"success": False, "error": "Invalid password"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if TOTP is enabled
|
||||||
|
if not Authenticator.objects.filter(user=user, type=Authenticator.Type.TOTP).exists():
|
||||||
|
return Response(
|
||||||
|
{"success": False, "error": "Two-factor authentication is not enabled"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate new codes
|
||||||
|
codes = recovery_auth.generate_recovery_codes()
|
||||||
|
|
||||||
|
# Update or create recovery codes authenticator
|
||||||
|
authenticator, created = Authenticator.objects.update_or_create(
|
||||||
|
user=user,
|
||||||
|
type=Authenticator.Type.RECOVERY_CODES,
|
||||||
|
defaults={"data": {"codes": codes}},
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
"success": True,
|
||||||
|
"recovery_codes": codes,
|
||||||
|
})
|
||||||
@@ -5,21 +5,21 @@ This module contains all serializers related to authentication, user accounts,
|
|||||||
profiles, top lists, and user statistics.
|
profiles, top lists, and user statistics.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from rest_framework import serializers
|
|
||||||
from drf_spectacular.utils import (
|
|
||||||
extend_schema_serializer,
|
|
||||||
extend_schema_field,
|
|
||||||
OpenApiExample,
|
|
||||||
)
|
|
||||||
from django.contrib.auth.password_validation import validate_password
|
|
||||||
from django.utils.crypto import get_random_string
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.utils import timezone
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from apps.accounts.models import PasswordReset
|
from typing import Any
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.password_validation import validate_password
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
|
from drf_spectacular.utils import (
|
||||||
|
OpenApiExample,
|
||||||
|
extend_schema_field,
|
||||||
|
extend_schema_serializer,
|
||||||
|
)
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.accounts.models import PasswordReset
|
||||||
|
|
||||||
UserModel = get_user_model()
|
UserModel = get_user_model()
|
||||||
|
|
||||||
@@ -37,16 +37,7 @@ def _normalize_email(value: str) -> str:
|
|||||||
class ModelChoices:
|
class ModelChoices:
|
||||||
"""Model choices utility class."""
|
"""Model choices utility class."""
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_top_list_categories():
|
|
||||||
"""Get top list category choices."""
|
|
||||||
return [
|
|
||||||
("RC", "Roller Coasters"),
|
|
||||||
("DR", "Dark Rides"),
|
|
||||||
("FR", "Flat Rides"),
|
|
||||||
("WR", "Water Rides"),
|
|
||||||
("PK", "Parks"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# === AUTHENTICATION SERIALIZERS ===
|
# === AUTHENTICATION SERIALIZERS ===
|
||||||
@@ -201,11 +192,13 @@ class SignupInputSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
def _send_verification_email(self, user):
|
def _send_verification_email(self, user):
|
||||||
"""Send email verification to the user."""
|
"""Send email verification to the user."""
|
||||||
from apps.accounts.models import EmailVerification
|
import logging
|
||||||
|
|
||||||
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
from django_forwardemail.services import EmailService
|
from django_forwardemail.services import EmailService
|
||||||
from django.contrib.sites.shortcuts import get_current_site
|
|
||||||
import logging
|
from apps.accounts.models import EmailVerification
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -445,7 +438,7 @@ class UserProfileOutputSerializer(serializers.Serializer):
|
|||||||
return obj.get_avatar_url()
|
return obj.get_avatar_url()
|
||||||
|
|
||||||
@extend_schema_field(serializers.DictField())
|
@extend_schema_field(serializers.DictField())
|
||||||
def get_user(self, obj) -> Dict[str, Any]:
|
def get_user(self, obj) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"username": obj.user.username,
|
"username": obj.user.username,
|
||||||
"date_joined": obj.user.date_joined,
|
"date_joined": obj.user.date_joined,
|
||||||
@@ -480,129 +473,4 @@ class UserProfileUpdateInputSerializer(serializers.Serializer):
|
|||||||
water_ride_credits = serializers.IntegerField(required=False)
|
water_ride_credits = serializers.IntegerField(required=False)
|
||||||
|
|
||||||
|
|
||||||
# === TOP LIST SERIALIZERS ===
|
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_serializer(
|
|
||||||
examples=[
|
|
||||||
OpenApiExample(
|
|
||||||
"Top List Example",
|
|
||||||
summary="Example top list response",
|
|
||||||
description="A user's top list of rides or parks",
|
|
||||||
value={
|
|
||||||
"id": 1,
|
|
||||||
"title": "My Top 10 Roller Coasters",
|
|
||||||
"category": "RC",
|
|
||||||
"description": "My favorite roller coasters ranked",
|
|
||||||
"user": {"username": "coaster_fan", "display_name": "Coaster Fan"},
|
|
||||||
"created_at": "2024-01-01T00:00:00Z",
|
|
||||||
"updated_at": "2024-08-15T12:00:00Z",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class TopListOutputSerializer(serializers.Serializer):
|
|
||||||
"""Output serializer for top lists."""
|
|
||||||
|
|
||||||
id = serializers.IntegerField()
|
|
||||||
title = serializers.CharField()
|
|
||||||
category = serializers.CharField()
|
|
||||||
description = serializers.CharField()
|
|
||||||
created_at = serializers.DateTimeField()
|
|
||||||
updated_at = serializers.DateTimeField()
|
|
||||||
|
|
||||||
# User info
|
|
||||||
user = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
@extend_schema_field(serializers.DictField())
|
|
||||||
def get_user(self, obj) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"username": obj.user.username,
|
|
||||||
"display_name": obj.user.get_display_name(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TopListCreateInputSerializer(serializers.Serializer):
|
|
||||||
"""Input serializer for creating top lists."""
|
|
||||||
|
|
||||||
title = serializers.CharField(max_length=100)
|
|
||||||
category = serializers.ChoiceField(choices=ModelChoices.get_top_list_categories())
|
|
||||||
description = serializers.CharField(allow_blank=True, default="")
|
|
||||||
|
|
||||||
|
|
||||||
class TopListUpdateInputSerializer(serializers.Serializer):
|
|
||||||
"""Input serializer for updating top lists."""
|
|
||||||
|
|
||||||
title = serializers.CharField(max_length=100, required=False)
|
|
||||||
category = serializers.ChoiceField(
|
|
||||||
choices=ModelChoices.get_top_list_categories(), required=False
|
|
||||||
)
|
|
||||||
description = serializers.CharField(allow_blank=True, required=False)
|
|
||||||
|
|
||||||
|
|
||||||
# === TOP LIST ITEM SERIALIZERS ===
|
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_serializer(
|
|
||||||
examples=[
|
|
||||||
OpenApiExample(
|
|
||||||
"Top List Item Example",
|
|
||||||
summary="Example top list item response",
|
|
||||||
description="An item in a user's top list",
|
|
||||||
value={
|
|
||||||
"id": 1,
|
|
||||||
"rank": 1,
|
|
||||||
"notes": "Amazing airtime and smooth ride",
|
|
||||||
"object_name": "Steel Vengeance",
|
|
||||||
"object_type": "Ride",
|
|
||||||
"top_list": {"id": 1, "title": "My Top 10 Roller Coasters"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class TopListItemOutputSerializer(serializers.Serializer):
|
|
||||||
"""Output serializer for top list items."""
|
|
||||||
|
|
||||||
id = serializers.IntegerField()
|
|
||||||
rank = serializers.IntegerField()
|
|
||||||
notes = serializers.CharField()
|
|
||||||
object_name = serializers.SerializerMethodField()
|
|
||||||
object_type = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
# Top list info
|
|
||||||
top_list = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
@extend_schema_field(serializers.CharField())
|
|
||||||
def get_object_name(self, obj) -> str:
|
|
||||||
"""Get the name of the referenced object."""
|
|
||||||
# This would need to be implemented based on the generic foreign key
|
|
||||||
return "Object Name" # Placeholder
|
|
||||||
|
|
||||||
@extend_schema_field(serializers.CharField())
|
|
||||||
def get_object_type(self, obj) -> str:
|
|
||||||
"""Get the type of the referenced object."""
|
|
||||||
return obj.content_type.model_class().__name__
|
|
||||||
|
|
||||||
@extend_schema_field(serializers.DictField())
|
|
||||||
def get_top_list(self, obj) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"id": obj.top_list.id,
|
|
||||||
"title": obj.top_list.title,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TopListItemCreateInputSerializer(serializers.Serializer):
|
|
||||||
"""Input serializer for creating top list items."""
|
|
||||||
|
|
||||||
top_list_id = serializers.IntegerField()
|
|
||||||
content_type_id = serializers.IntegerField()
|
|
||||||
object_id = serializers.IntegerField()
|
|
||||||
rank = serializers.IntegerField(min_value=1)
|
|
||||||
notes = serializers.CharField(allow_blank=True, default="")
|
|
||||||
|
|
||||||
|
|
||||||
class TopListItemUpdateInputSerializer(serializers.Serializer):
|
|
||||||
"""Input serializer for updating top list items."""
|
|
||||||
|
|
||||||
rank = serializers.IntegerField(min_value=1, required=False)
|
|
||||||
notes = serializers.CharField(allow_blank=True, required=False)
|
|
||||||
|
|||||||
@@ -6,15 +6,15 @@ Main authentication serializers are imported directly from the parent serializer
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .social import (
|
from .social import (
|
||||||
ConnectedProviderSerializer,
|
|
||||||
AvailableProviderSerializer,
|
AvailableProviderSerializer,
|
||||||
SocialAuthStatusSerializer,
|
ConnectedProviderSerializer,
|
||||||
|
ConnectedProvidersListOutputSerializer,
|
||||||
ConnectProviderInputSerializer,
|
ConnectProviderInputSerializer,
|
||||||
ConnectProviderOutputSerializer,
|
ConnectProviderOutputSerializer,
|
||||||
DisconnectProviderOutputSerializer,
|
DisconnectProviderOutputSerializer,
|
||||||
SocialProviderListOutputSerializer,
|
SocialAuthStatusSerializer,
|
||||||
ConnectedProvidersListOutputSerializer,
|
|
||||||
SocialProviderErrorSerializer,
|
SocialProviderErrorSerializer,
|
||||||
|
SocialProviderListOutputSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ Serializers for handling social provider connection/disconnection requests
|
|||||||
and responses in the ThrillWiki API.
|
and responses in the ThrillWiki API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from rest_framework import serializers
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|||||||
@@ -5,29 +5,30 @@ This module contains URL patterns for core authentication functionality only.
|
|||||||
User profiles and top lists are handled by the dedicated accounts app.
|
User profiles and top lists are handled by the dedicated accounts app.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.urls import path, include
|
from django.urls import include, path
|
||||||
|
from rest_framework_simplejwt.views import TokenRefreshView
|
||||||
|
|
||||||
|
from . import mfa as mfa_views
|
||||||
from .views import (
|
from .views import (
|
||||||
# Main auth views
|
|
||||||
LoginAPIView,
|
|
||||||
SignupAPIView,
|
|
||||||
LogoutAPIView,
|
|
||||||
CurrentUserAPIView,
|
|
||||||
PasswordResetAPIView,
|
|
||||||
PasswordChangeAPIView,
|
|
||||||
SocialProvidersAPIView,
|
|
||||||
AuthStatusAPIView,
|
AuthStatusAPIView,
|
||||||
# Email verification views
|
|
||||||
EmailVerificationAPIView,
|
|
||||||
ResendVerificationAPIView,
|
|
||||||
# Social provider management views
|
# Social provider management views
|
||||||
AvailableProvidersAPIView,
|
AvailableProvidersAPIView,
|
||||||
ConnectedProvidersAPIView,
|
ConnectedProvidersAPIView,
|
||||||
ConnectProviderAPIView,
|
ConnectProviderAPIView,
|
||||||
|
CurrentUserAPIView,
|
||||||
DisconnectProviderAPIView,
|
DisconnectProviderAPIView,
|
||||||
|
# Email verification views
|
||||||
|
EmailVerificationAPIView,
|
||||||
|
# Main auth views
|
||||||
|
LoginAPIView,
|
||||||
|
LogoutAPIView,
|
||||||
|
PasswordChangeAPIView,
|
||||||
|
PasswordResetAPIView,
|
||||||
|
ResendVerificationAPIView,
|
||||||
|
SignupAPIView,
|
||||||
SocialAuthStatusAPIView,
|
SocialAuthStatusAPIView,
|
||||||
|
SocialProvidersAPIView,
|
||||||
)
|
)
|
||||||
from rest_framework_simplejwt.views import TokenRefreshView
|
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Core authentication endpoints
|
# Core authentication endpoints
|
||||||
@@ -98,6 +99,14 @@ urlpatterns = [
|
|||||||
ResendVerificationAPIView.as_view(),
|
ResendVerificationAPIView.as_view(),
|
||||||
name="auth-resend-verification",
|
name="auth-resend-verification",
|
||||||
),
|
),
|
||||||
|
|
||||||
|
# MFA (Multi-Factor Authentication) endpoints
|
||||||
|
path("mfa/status/", mfa_views.get_mfa_status, name="auth-mfa-status"),
|
||||||
|
path("mfa/totp/setup/", mfa_views.setup_totp, name="auth-mfa-totp-setup"),
|
||||||
|
path("mfa/totp/activate/", mfa_views.activate_totp, name="auth-mfa-totp-activate"),
|
||||||
|
path("mfa/totp/deactivate/", mfa_views.deactivate_totp, name="auth-mfa-totp-deactivate"),
|
||||||
|
path("mfa/totp/verify/", mfa_views.verify_totp, name="auth-mfa-totp-verify"),
|
||||||
|
path("mfa/recovery-codes/regenerate/", mfa_views.regenerate_recovery_codes, name="auth-mfa-recovery-regenerate"),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Note: User profiles and top lists functionality is now handled by the accounts app
|
# Note: User profiles and top lists functionality is now handled by the accounts app
|
||||||
|
|||||||
@@ -6,44 +6,46 @@ login, signup, logout, password management, social authentication,
|
|||||||
user profiles, and top lists.
|
user profiles, and top lists.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .serializers_package.social import (
|
from typing import cast # added 'cast'
|
||||||
ConnectedProviderSerializer,
|
|
||||||
AvailableProviderSerializer,
|
from django.contrib.auth import authenticate, get_user_model, login, logout
|
||||||
SocialAuthStatusSerializer,
|
|
||||||
ConnectProviderInputSerializer,
|
|
||||||
ConnectProviderOutputSerializer,
|
|
||||||
DisconnectProviderOutputSerializer,
|
|
||||||
SocialProviderErrorSerializer,
|
|
||||||
)
|
|
||||||
from apps.accounts.services.social_provider_service import SocialProviderService
|
|
||||||
from django.contrib.auth import authenticate, login, logout, get_user_model
|
|
||||||
from django.contrib.sites.shortcuts import get_current_site
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from typing import Optional, cast # added 'cast'
|
|
||||||
from django.http import HttpRequest # new import
|
from django.http import HttpRequest # new import
|
||||||
|
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.views import APIView
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
from rest_framework.views import APIView
|
||||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
|
||||||
|
from apps.accounts.services.social_provider_service import SocialProviderService
|
||||||
|
|
||||||
# Import directly from the auth serializers.py file (not the serializers package)
|
# Import directly from the auth serializers.py file (not the serializers package)
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
|
AuthStatusOutputSerializer,
|
||||||
# Authentication serializers
|
# Authentication serializers
|
||||||
LoginInputSerializer,
|
LoginInputSerializer,
|
||||||
LoginOutputSerializer,
|
LoginOutputSerializer,
|
||||||
SignupInputSerializer,
|
|
||||||
SignupOutputSerializer,
|
|
||||||
LogoutOutputSerializer,
|
LogoutOutputSerializer,
|
||||||
UserOutputSerializer,
|
|
||||||
PasswordResetInputSerializer,
|
|
||||||
PasswordResetOutputSerializer,
|
|
||||||
PasswordChangeInputSerializer,
|
PasswordChangeInputSerializer,
|
||||||
PasswordChangeOutputSerializer,
|
PasswordChangeOutputSerializer,
|
||||||
|
PasswordResetInputSerializer,
|
||||||
|
PasswordResetOutputSerializer,
|
||||||
|
SignupInputSerializer,
|
||||||
|
SignupOutputSerializer,
|
||||||
SocialProviderOutputSerializer,
|
SocialProviderOutputSerializer,
|
||||||
AuthStatusOutputSerializer,
|
UserOutputSerializer,
|
||||||
|
)
|
||||||
|
from .serializers_package.social import (
|
||||||
|
AvailableProviderSerializer,
|
||||||
|
ConnectedProviderSerializer,
|
||||||
|
ConnectProviderInputSerializer,
|
||||||
|
ConnectProviderOutputSerializer,
|
||||||
|
DisconnectProviderOutputSerializer,
|
||||||
|
SocialAuthStatusSerializer,
|
||||||
|
SocialProviderErrorSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle optional dependencies with fallback classes
|
# Handle optional dependencies with fallback classes
|
||||||
@@ -62,10 +64,7 @@ try:
|
|||||||
|
|
||||||
# Ensure the imported object is a class/type that can be used as a base class.
|
# Ensure the imported object is a class/type that can be used as a base class.
|
||||||
# If it's not a type for any reason, fall back to the safe mixin.
|
# If it's not a type for any reason, fall back to the safe mixin.
|
||||||
if isinstance(_ImportedTurnstileMixin, type):
|
TurnstileMixin = _ImportedTurnstileMixin if isinstance(_ImportedTurnstileMixin, type) else FallbackTurnstileMixin
|
||||||
TurnstileMixin = _ImportedTurnstileMixin
|
|
||||||
else:
|
|
||||||
TurnstileMixin = FallbackTurnstileMixin
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Catch any import errors or unexpected exceptions and use the fallback mixin.
|
# Catch any import errors or unexpected exceptions and use the fallback mixin.
|
||||||
TurnstileMixin = FallbackTurnstileMixin
|
TurnstileMixin = FallbackTurnstileMixin
|
||||||
@@ -88,7 +87,7 @@ def _get_underlying_request(request: Request) -> HttpRequest:
|
|||||||
# Helper: encapsulate user lookup + authenticate to reduce complexity in view
|
# Helper: encapsulate user lookup + authenticate to reduce complexity in view
|
||||||
def _authenticate_user_by_lookup(
|
def _authenticate_user_by_lookup(
|
||||||
email_or_username: str, password: str, request: Request
|
email_or_username: str, password: str, request: Request
|
||||||
) -> Optional[UserModel]:
|
) -> UserModel | None:
|
||||||
"""
|
"""
|
||||||
Try a single optimized query to find a user by email OR username then authenticate.
|
Try a single optimized query to find a user by email OR username then authenticate.
|
||||||
Returns authenticated user or None.
|
Returns authenticated user or None.
|
||||||
@@ -798,10 +797,11 @@ class ResendVerificationAPIView(APIView):
|
|||||||
authentication_classes = []
|
authentication_classes = []
|
||||||
|
|
||||||
def post(self, request: Request) -> Response:
|
def post(self, request: Request) -> Response:
|
||||||
from apps.accounts.models import EmailVerification
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
from django_forwardemail.services import EmailService
|
from django_forwardemail.services import EmailService
|
||||||
from django.contrib.sites.shortcuts import get_current_site
|
|
||||||
|
from apps.accounts.models import EmailVerification
|
||||||
|
|
||||||
email = request.data.get('email')
|
email = request.data.get('email')
|
||||||
if not email:
|
if not email:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Centralized from apps.core.urls
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
# Entity search endpoints - migrated from apps.core.urls
|
# Entity search endpoints - migrated from apps.core.urls
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
"""
|
"""
|
||||||
Centralized core API views.
|
Centralized core API views.
|
||||||
Migrated from apps.core.views.entity_search
|
Migrated from apps.core.views.entity_search
|
||||||
|
|
||||||
|
Caching Strategy:
|
||||||
|
- QuickEntitySuggestionView: 5 minutes (300s) - autocomplete should be fast and relatively fresh
|
||||||
|
- EntityFuzzySearchView: No caching - POST requests with varying data
|
||||||
|
- EntityNotFoundView: No caching - POST requests with context-specific data
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from rest_framework.views import APIView
|
|
||||||
from rest_framework.response import Response
|
import contextlib
|
||||||
|
|
||||||
|
from drf_spectacular.utils import extend_schema
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.permissions import AllowAny
|
from rest_framework.permissions import AllowAny
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from rest_framework.response import Response
|
||||||
from django.utils.decorators import method_decorator
|
from rest_framework.views import APIView
|
||||||
from typing import Optional, List
|
|
||||||
from drf_spectacular.utils import extend_schema
|
|
||||||
|
|
||||||
|
from apps.core.decorators.cache_decorators import cache_api_response
|
||||||
from apps.core.services.entity_fuzzy_matching import (
|
from apps.core.services.entity_fuzzy_matching import (
|
||||||
entity_fuzzy_matcher,
|
|
||||||
EntityType,
|
EntityType,
|
||||||
|
entity_fuzzy_matcher,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -195,10 +201,8 @@ class EntityNotFoundView(APIView):
|
|||||||
# Determine entity types to search based on context
|
# Determine entity types to search based on context
|
||||||
entity_types = []
|
entity_types = []
|
||||||
if entity_type_hint:
|
if entity_type_hint:
|
||||||
try:
|
with contextlib.suppress(ValueError):
|
||||||
entity_types = [EntityType(entity_type_hint)]
|
entity_types = [EntityType(entity_type_hint)]
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# If we have park context, prioritize ride searches
|
# If we have park context, prioritize ride searches
|
||||||
if context.get("park_slug") and not entity_types:
|
if context.get("park_slug") and not entity_types:
|
||||||
@@ -260,12 +264,14 @@ class EntityNotFoundView(APIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt, name="dispatch")
|
|
||||||
class QuickEntitySuggestionView(APIView):
|
class QuickEntitySuggestionView(APIView):
|
||||||
"""
|
"""
|
||||||
Lightweight endpoint for quick entity suggestions (e.g., autocomplete).
|
Lightweight endpoint for quick entity suggestions (e.g., autocomplete).
|
||||||
|
|
||||||
Migrated from apps.core.views.entity_search.QuickEntitySuggestionView
|
Migrated from apps.core.views.entity_search.QuickEntitySuggestionView
|
||||||
|
|
||||||
|
Security Note: This endpoint only accepts GET requests, which are inherently
|
||||||
|
safe from CSRF attacks. No CSRF exemption is needed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
@@ -275,6 +281,7 @@ class QuickEntitySuggestionView(APIView):
|
|||||||
summary="Quick entity suggestions",
|
summary="Quick entity suggestions",
|
||||||
description="Lightweight endpoint for quick entity suggestions (e.g., autocomplete)",
|
description="Lightweight endpoint for quick entity suggestions (e.g., autocomplete)",
|
||||||
)
|
)
|
||||||
|
@cache_api_response(timeout=300, key_prefix="entity_suggestions")
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
"""
|
"""
|
||||||
Get quick entity suggestions.
|
Get quick entity suggestions.
|
||||||
@@ -337,7 +344,7 @@ class QuickEntitySuggestionView(APIView):
|
|||||||
|
|
||||||
# Utility function for other views to use
|
# Utility function for other views to use
|
||||||
def get_entity_suggestions(
|
def get_entity_suggestions(
|
||||||
query: str, entity_types: Optional[List[str]] = None, user=None
|
query: str, entity_types: list[str] | None = None, user=None
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Utility function for other Django views to get entity suggestions.
|
Utility function for other Django views to get entity suggestions.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Centralized from apps.email_service.urls
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ Centralized email service API views.
|
|||||||
Migrated from apps.email_service.views
|
Migrated from apps.email_service.views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from rest_framework.views import APIView
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
from rest_framework.response import Response
|
from django_forwardemail.services import EmailService
|
||||||
|
from drf_spectacular.utils import extend_schema
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.permissions import AllowAny
|
from rest_framework.permissions import AllowAny
|
||||||
from django.contrib.sites.shortcuts import get_current_site
|
from rest_framework.response import Response
|
||||||
from drf_spectacular.utils import extend_schema
|
from rest_framework.views import APIView
|
||||||
from django_forwardemail.services import EmailService
|
|
||||||
|
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ History API URLs
|
|||||||
URL patterns for history-related API endpoints.
|
URL patterns for history-related API endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.urls import path, include
|
from django.urls import include, path
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from .views import (
|
from .views import (
|
||||||
|
|||||||
@@ -5,18 +5,21 @@ This module provides ViewSets for accessing historical data and change tracking
|
|||||||
across all models in the ThrillWiki system using django-pghistory.
|
across all models in the ThrillWiki system using django-pghistory.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import pghistory.models
|
||||||
|
from django.db.models import Count, QuerySet
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view
|
||||||
|
from rest_framework import serializers as drf_serializers
|
||||||
from rest_framework.filters import OrderingFilter
|
from rest_framework.filters import OrderingFilter
|
||||||
from rest_framework.permissions import AllowAny
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||||
from rest_framework.request import Request
|
|
||||||
from typing import Optional, cast, Sequence
|
|
||||||
from django.shortcuts import get_object_or_404
|
|
||||||
from django.db.models import Count, QuerySet
|
|
||||||
import pghistory.models
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
# Import models
|
# Import models
|
||||||
from apps.parks.models import Park
|
from apps.parks.models import Park
|
||||||
@@ -24,7 +27,6 @@ from apps.rides.models import Ride
|
|||||||
|
|
||||||
# Import serializers
|
# Import serializers
|
||||||
from .. import serializers as history_serializers
|
from .. import serializers as history_serializers
|
||||||
from rest_framework import serializers as drf_serializers
|
|
||||||
|
|
||||||
# Minimal fallback serializer used when a specific serializer symbol is missing.
|
# Minimal fallback serializer used when a specific serializer symbol is missing.
|
||||||
|
|
||||||
@@ -79,7 +81,7 @@ ALL_TRACKED_MODELS: Sequence[str] = [
|
|||||||
# --- Helper utilities to reduce duplicated logic / cognitive complexity ---
|
# --- Helper utilities to reduce duplicated logic / cognitive complexity ---
|
||||||
|
|
||||||
|
|
||||||
def _parse_date(date_str: Optional[str]) -> Optional[datetime]:
|
def _parse_date(date_str: str | None) -> datetime | None:
|
||||||
if not date_str:
|
if not date_str:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
|
|||||||
7
backend/apps/api/v1/images/urls.py
Normal file
7
backend/apps/api/v1/images/urls.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import GenerateUploadURLView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("generate-upload-url/", GenerateUploadURLView.as_view(), name="generate-upload-url"),
|
||||||
|
]
|
||||||
39
backend/apps/api/v1/images/views.py
Normal file
39
backend/apps/api/v1/images/views.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from apps.core.utils.cloudflare import get_direct_upload_url
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class GenerateUploadURLView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
try:
|
||||||
|
# Pass user_id for metadata if needed
|
||||||
|
result = get_direct_upload_url(user_id=str(request.user.id))
|
||||||
|
return Response(result, status=status.HTTP_200_OK)
|
||||||
|
except ImproperlyConfigured as e:
|
||||||
|
logger.error(f"Configuration Error: {e}")
|
||||||
|
return Response(
|
||||||
|
{"detail": "Server configuration error."},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(f"Cloudflare API Error: {e}")
|
||||||
|
return Response(
|
||||||
|
{"detail": "Failed to generate upload URL."},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Unexpected error generating upload URL")
|
||||||
|
return Response(
|
||||||
|
{"detail": "An unexpected error occurred."},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
@@ -4,6 +4,7 @@ Migrated from apps.core.urls.map_urls to centralized API structure.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
# Map API endpoints - migrated from apps.core.urls.map_urls
|
# Map API endpoints - migrated from apps.core.urls.map_urls
|
||||||
|
|||||||
@@ -1,32 +1,42 @@
|
|||||||
"""
|
"""
|
||||||
Centralized map API views.
|
Centralized map API views.
|
||||||
Migrated from apps.core.views.map_views
|
Migrated from apps.core.views.map_views
|
||||||
|
|
||||||
|
Caching Strategy:
|
||||||
|
- MapLocationsAPIView: 5 minutes (300s) - map data changes infrequently but needs freshness
|
||||||
|
- MapLocationDetailAPIView: 30 minutes (1800s) - detail views are stable
|
||||||
|
- MapSearchAPIView: 5 minutes (300s) - search results should be consistent
|
||||||
|
- MapBoundsAPIView: 5 minutes (300s) - bounds queries are location-specific
|
||||||
|
- MapStatsAPIView: 10 minutes (600s) - stats are aggregated and change slowly
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.http import HttpRequest
|
|
||||||
from django.db.models import Q
|
|
||||||
from django.core.cache import cache
|
|
||||||
from django.contrib.gis.geos import Polygon
|
from django.contrib.gis.geos import Polygon
|
||||||
from rest_framework.views import APIView
|
from django.core.cache import cache
|
||||||
from rest_framework.response import Response
|
from django.db.models import Q
|
||||||
from rest_framework import status
|
from django.http import HttpRequest
|
||||||
from rest_framework.permissions import AllowAny
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import (
|
from drf_spectacular.utils import (
|
||||||
|
OpenApiExample,
|
||||||
|
OpenApiParameter,
|
||||||
extend_schema,
|
extend_schema,
|
||||||
extend_schema_view,
|
extend_schema_view,
|
||||||
OpenApiParameter,
|
|
||||||
OpenApiExample,
|
|
||||||
)
|
)
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from rest_framework import status
|
||||||
|
from rest_framework.permissions import AllowAny, IsAdminUser
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from apps.core.decorators.cache_decorators import cache_api_response
|
||||||
|
from apps.core.services.enhanced_cache_service import EnhancedCacheService
|
||||||
from apps.parks.models import Park
|
from apps.parks.models import Park
|
||||||
from apps.rides.models import Ride
|
from apps.rides.models import Ride
|
||||||
|
|
||||||
from ..serializers.maps import (
|
from ..serializers.maps import (
|
||||||
|
MapLocationDetailSerializer,
|
||||||
MapLocationsResponseSerializer,
|
MapLocationsResponseSerializer,
|
||||||
MapSearchResponseSerializer,
|
MapSearchResponseSerializer,
|
||||||
MapLocationDetailSerializer,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -306,21 +316,28 @@ class MapLocationsAPIView(APIView):
|
|||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"locations": locations,
|
"locations": locations,
|
||||||
"clusters": [], # TODO: Implement clustering
|
"clusters": [], # See FUTURE_WORK.md - THRILLWIKI-106 for implementation plan
|
||||||
"bounds": self._calculate_bounds(locations),
|
"bounds": self._calculate_bounds(locations),
|
||||||
"total_count": len(locations),
|
"total_count": len(locations),
|
||||||
"clustered": params["cluster"],
|
"clustered": params["cluster"],
|
||||||
}
|
}
|
||||||
|
|
||||||
def get(self, request: HttpRequest) -> Response:
|
def get(self, request: HttpRequest) -> Response:
|
||||||
"""Get map locations with optional clustering and filtering."""
|
"""
|
||||||
|
Get map locations with optional clustering and filtering.
|
||||||
|
|
||||||
|
Caching: Uses EnhancedCacheService with 5-minute timeout (300s).
|
||||||
|
Cache key is based on all query parameters for proper invalidation.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
params = self._parse_request_parameters(request)
|
params = self._parse_request_parameters(request)
|
||||||
cache_key = self._build_cache_key(params)
|
cache_key = self._build_cache_key(params)
|
||||||
|
|
||||||
# Check cache first
|
# Use EnhancedCacheService for improved caching with monitoring
|
||||||
cached_result = cache.get(cache_key)
|
cache_service = EnhancedCacheService()
|
||||||
|
cached_result = cache_service.get_cached_api_response('map_locations', params)
|
||||||
if cached_result:
|
if cached_result:
|
||||||
|
logger.debug(f"Cache hit for map_locations with key: {cache_key}")
|
||||||
return Response(cached_result)
|
return Response(cached_result)
|
||||||
|
|
||||||
# Get location data
|
# Get location data
|
||||||
@@ -331,8 +348,9 @@ class MapLocationsAPIView(APIView):
|
|||||||
# Build response
|
# Build response
|
||||||
result = self._build_response(locations, params)
|
result = self._build_response(locations, params)
|
||||||
|
|
||||||
# Cache result for 5 minutes
|
# Cache result for 5 minutes using EnhancedCacheService
|
||||||
cache.set(cache_key, result, 300)
|
cache_service.cache_api_response('map_locations', params, result, timeout=300)
|
||||||
|
logger.debug(f"Cached map_locations result for key: {cache_key}")
|
||||||
|
|
||||||
return Response(result)
|
return Response(result)
|
||||||
|
|
||||||
@@ -374,10 +392,15 @@ class MapLocationsAPIView(APIView):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
class MapLocationDetailAPIView(APIView):
|
class MapLocationDetailAPIView(APIView):
|
||||||
"""API endpoint for getting detailed information about a specific location."""
|
"""
|
||||||
|
API endpoint for getting detailed information about a specific location.
|
||||||
|
|
||||||
|
Caching: 30-minute timeout (1800s) - detail views are stable and change infrequently.
|
||||||
|
"""
|
||||||
|
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
@cache_api_response(timeout=1800, key_prefix="map_detail")
|
||||||
def get(
|
def get(
|
||||||
self, request: HttpRequest, location_type: str, location_id: int
|
self, request: HttpRequest, location_type: str, location_id: int
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@@ -471,7 +494,7 @@ class MapLocationDetailAPIView(APIView):
|
|||||||
obj.opening_date.isoformat() if obj.opening_date else None
|
obj.opening_date.isoformat() if obj.opening_date else None
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"nearby_locations": [], # TODO: Implement nearby locations
|
"nearby_locations": [], # See FUTURE_WORK.md - THRILLWIKI-107
|
||||||
}
|
}
|
||||||
else: # ride
|
else: # ride
|
||||||
data = {
|
data = {
|
||||||
@@ -538,7 +561,7 @@ class MapLocationDetailAPIView(APIView):
|
|||||||
obj.manufacturer.name if obj.manufacturer else None
|
obj.manufacturer.name if obj.manufacturer else None
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"nearby_locations": [], # TODO: Implement nearby locations
|
"nearby_locations": [], # See FUTURE_WORK.md - THRILLWIKI-107
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
@@ -599,10 +622,16 @@ class MapLocationDetailAPIView(APIView):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
class MapSearchAPIView(APIView):
|
class MapSearchAPIView(APIView):
|
||||||
"""API endpoint for searching locations by text query."""
|
"""
|
||||||
|
API endpoint for searching locations by text query.
|
||||||
|
|
||||||
|
Caching: 5-minute timeout (300s) - search results should remain consistent
|
||||||
|
but need to reflect new content additions.
|
||||||
|
"""
|
||||||
|
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
@cache_api_response(timeout=300, key_prefix="map_search")
|
||||||
def get(self, request: HttpRequest) -> Response:
|
def get(self, request: HttpRequest) -> Response:
|
||||||
"""Search locations by text query with pagination."""
|
"""Search locations by text query with pagination."""
|
||||||
try:
|
try:
|
||||||
@@ -669,7 +698,7 @@ class MapSearchAPIView(APIView):
|
|||||||
else ""
|
else ""
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"relevance_score": 1.0, # TODO: Implement relevance scoring
|
"relevance_score": 1.0, # See FUTURE_WORK.md - THRILLWIKI-108
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -722,7 +751,7 @@ class MapSearchAPIView(APIView):
|
|||||||
else ""
|
else ""
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"relevance_score": 1.0, # TODO: Implement relevance scoring
|
"relevance_score": 1.0, # See FUTURE_WORK.md - THRILLWIKI-108
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -798,10 +827,16 @@ class MapSearchAPIView(APIView):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
class MapBoundsAPIView(APIView):
|
class MapBoundsAPIView(APIView):
|
||||||
"""API endpoint for getting locations within specific bounds."""
|
"""
|
||||||
|
API endpoint for getting locations within specific bounds.
|
||||||
|
|
||||||
|
Caching: 5-minute timeout (300s) - bounds queries are location-specific
|
||||||
|
and may be repeated during map navigation.
|
||||||
|
"""
|
||||||
|
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
@cache_api_response(timeout=300, key_prefix="map_bounds")
|
||||||
def get(self, request: HttpRequest) -> Response:
|
def get(self, request: HttpRequest) -> Response:
|
||||||
"""Get locations within specific geographic bounds."""
|
"""Get locations within specific geographic bounds."""
|
||||||
try:
|
try:
|
||||||
@@ -925,10 +960,7 @@ class MapBoundsAPIView(APIView):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in MapBoundsAPIView: {str(e)}", exc_info=True)
|
logger.error(f"Error in MapBoundsAPIView: {str(e)}", exc_info=True)
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{"status": "error", "message": "Failed to retrieve locations within bounds"},
|
||||||
"status": "error",
|
|
||||||
"message": "Failed to retrieve locations within bounds",
|
|
||||||
},
|
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -942,10 +974,15 @@ class MapBoundsAPIView(APIView):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
class MapStatsAPIView(APIView):
|
class MapStatsAPIView(APIView):
|
||||||
"""API endpoint for getting map service statistics and health information."""
|
"""
|
||||||
|
API endpoint for getting map service statistics and health information.
|
||||||
|
|
||||||
|
Caching: 10-minute timeout (600s) - stats are aggregated and change slowly.
|
||||||
|
"""
|
||||||
|
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
@cache_api_response(timeout=600, key_prefix="map_stats")
|
||||||
def get(self, request: HttpRequest) -> Response:
|
def get(self, request: HttpRequest) -> Response:
|
||||||
"""Get map service statistics and performance metrics."""
|
"""Get map service statistics and performance metrics."""
|
||||||
try:
|
try:
|
||||||
@@ -958,23 +995,28 @@ class MapStatsAPIView(APIView):
|
|||||||
).count()
|
).count()
|
||||||
total_locations = parks_with_location + rides_with_location
|
total_locations = parks_with_location + rides_with_location
|
||||||
|
|
||||||
|
# Get cache statistics
|
||||||
|
from apps.core.services.enhanced_cache_service import CacheMonitor
|
||||||
|
cache_monitor = CacheMonitor()
|
||||||
|
cache_stats = cache_monitor.get_cache_statistics('map_locations')
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"data": {
|
"total_locations": total_locations,
|
||||||
"total_locations": total_locations,
|
"parks_with_location": parks_with_location,
|
||||||
"parks_with_location": parks_with_location,
|
"rides_with_location": rides_with_location,
|
||||||
"rides_with_location": rides_with_location,
|
"cache_hits": cache_stats.get('hits', 0),
|
||||||
"cache_hits": 0, # TODO: Implement cache statistics
|
"cache_misses": cache_stats.get('misses', 0),
|
||||||
"cache_misses": 0, # TODO: Implement cache statistics
|
"cache_hit_rate": cache_stats.get('hit_rate', 0.0),
|
||||||
},
|
"cache_size": cache_stats.get('size', 0),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in MapStatsAPIView: {str(e)}", exc_info=True)
|
logger.error(f"Error in MapStatsAPIView: {str(e)}", exc_info=True)
|
||||||
return Response(
|
return Response(
|
||||||
{"error": f"Internal server error: {str(e)}"},
|
{"status": "error", "message": "Failed to retrieve map statistics"},
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -996,7 +1038,7 @@ class MapStatsAPIView(APIView):
|
|||||||
class MapCacheAPIView(APIView):
|
class MapCacheAPIView(APIView):
|
||||||
"""API endpoint for cache management (admin only)."""
|
"""API endpoint for cache management (admin only)."""
|
||||||
|
|
||||||
permission_classes = [AllowAny] # TODO: Add admin permission check
|
permission_classes = [IsAdminUser] # Admin only
|
||||||
|
|
||||||
def delete(self, request: HttpRequest) -> Response:
|
def delete(self, request: HttpRequest) -> Response:
|
||||||
"""Clear all map cache (admin only)."""
|
"""Clear all map cache (admin only)."""
|
||||||
@@ -1019,13 +1061,14 @@ class MapCacheAPIView(APIView):
|
|||||||
{
|
{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": f"Map cache cleared successfully. Cleared {cleared_count} entries.",
|
"message": f"Map cache cleared successfully. Cleared {cleared_count} entries.",
|
||||||
|
"cleared_count": cleared_count,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in MapCacheAPIView.delete: {str(e)}", exc_info=True)
|
logger.error(f"Error in MapCacheAPIView.delete: {str(e)}", exc_info=True)
|
||||||
return Response(
|
return Response(
|
||||||
{"error": f"Internal server error: {str(e)}"},
|
{"status": "error", "message": "Failed to clear map cache"},
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1046,13 +1089,14 @@ class MapCacheAPIView(APIView):
|
|||||||
{
|
{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": f"Cache invalidated successfully. Invalidated {invalidated_count} entries.",
|
"message": f"Cache invalidated successfully. Invalidated {invalidated_count} entries.",
|
||||||
|
"invalidated_count": invalidated_count,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in MapCacheAPIView.post: {str(e)}", exc_info=True)
|
logger.error(f"Error in MapCacheAPIView.post: {str(e)}", exc_info=True)
|
||||||
return Response(
|
return Response(
|
||||||
{"error": f"Internal server error: {str(e)}"},
|
{"status": "error", "message": "Failed to invalidate cache"},
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ TypeScript interfaces, providing immediate feedback during development.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any
|
from typing import Any
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.utils.deprecation import MiddlewareMixin
|
from django.utils.deprecation import MiddlewareMixin
|
||||||
@@ -57,10 +58,7 @@ class ContractValidationMiddleware(MiddlewareMixin):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Get response data
|
# Get response data
|
||||||
if isinstance(response, Response):
|
data = response.data if isinstance(response, Response) else json.loads(response.content.decode('utf-8'))
|
||||||
data = response.data
|
|
||||||
else:
|
|
||||||
data = json.loads(response.content.decode('utf-8'))
|
|
||||||
|
|
||||||
# Validate the response
|
# Validate the response
|
||||||
self._validate_response_contract(request.path, data)
|
self._validate_response_contract(request.path, data)
|
||||||
@@ -213,7 +211,7 @@ class ContractValidationMiddleware(MiddlewareMixin):
|
|||||||
if 'filter_metadata' in data:
|
if 'filter_metadata' in data:
|
||||||
self._validate_filter_metadata(path, data['filter_metadata'])
|
self._validate_filter_metadata(path, data['filter_metadata'])
|
||||||
|
|
||||||
def _validate_pagination_response(self, path: str, data: Dict[str, Any]) -> None:
|
def _validate_pagination_response(self, path: str, data: dict[str, Any]) -> None:
|
||||||
"""Validate pagination response structure."""
|
"""Validate pagination response structure."""
|
||||||
|
|
||||||
# Check for required pagination fields
|
# Check for required pagination fields
|
||||||
|
|||||||
90
backend/apps/api/v1/parks/history_views.py
Normal file
90
backend/apps/api/v1/parks/history_views.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""
|
||||||
|
Park history API views.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from drf_spectacular.utils import extend_schema
|
||||||
|
from rest_framework import viewsets
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from apps.api.v1.serializers.history import ParkHistoryOutputSerializer, RideHistoryOutputSerializer
|
||||||
|
from apps.parks.models import Park
|
||||||
|
from apps.rides.models import Ride
|
||||||
|
|
||||||
|
|
||||||
|
class ParkHistoryViewSet(viewsets.GenericViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for retrieving park history.
|
||||||
|
"""
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
lookup_field = "slug"
|
||||||
|
lookup_url_kwarg = "park_slug"
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary="Get park history",
|
||||||
|
description="Retrieve history events for a park.",
|
||||||
|
responses={200: ParkHistoryOutputSerializer},
|
||||||
|
tags=["Park History"],
|
||||||
|
)
|
||||||
|
def list(self, request, park_slug=None):
|
||||||
|
park = get_object_or_404(Park, slug=park_slug)
|
||||||
|
|
||||||
|
events = []
|
||||||
|
if hasattr(park, "events"):
|
||||||
|
events = park.events.all().order_by("-pgh_created_at")
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"total_events": len(events),
|
||||||
|
"first_recorded": events.last().pgh_created_at if len(events) else None,
|
||||||
|
"last_modified": events.first().pgh_created_at if len(events) else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"park": park,
|
||||||
|
"current_state": park,
|
||||||
|
"summary": summary,
|
||||||
|
"events": events
|
||||||
|
}
|
||||||
|
|
||||||
|
serializer = ParkHistoryOutputSerializer(data)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class RideHistoryViewSet(viewsets.GenericViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for retrieving ride history.
|
||||||
|
"""
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
lookup_field = "slug"
|
||||||
|
lookup_url_kwarg = "ride_slug"
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary="Get ride history",
|
||||||
|
description="Retrieve history events for a ride.",
|
||||||
|
responses={200: RideHistoryOutputSerializer},
|
||||||
|
tags=["Ride History"],
|
||||||
|
)
|
||||||
|
def list(self, request, park_slug=None, ride_slug=None):
|
||||||
|
park = get_object_or_404(Park, slug=park_slug)
|
||||||
|
ride = get_object_or_404(Ride, slug=ride_slug, park=park)
|
||||||
|
|
||||||
|
events = []
|
||||||
|
if hasattr(ride, "events"):
|
||||||
|
events = ride.events.all().order_by("-pgh_created_at")
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"total_events": len(events),
|
||||||
|
"first_recorded": events.last().pgh_created_at if len(events) else None,
|
||||||
|
"last_modified": events.first().pgh_created_at if len(events) else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"ride": ride,
|
||||||
|
"current_state": ride,
|
||||||
|
"summary": summary,
|
||||||
|
"events": events
|
||||||
|
}
|
||||||
|
|
||||||
|
serializer = RideHistoryOutputSerializer(data)
|
||||||
|
return Response(serializer.data)
|
||||||
158
backend/apps/api/v1/parks/park_reviews_views.py
Normal file
158
backend/apps/api/v1/parks/park_reviews_views.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"""
|
||||||
|
Park review API views for ThrillWiki API v1.
|
||||||
|
|
||||||
|
This module contains park review ViewSet following the reviews pattern.
|
||||||
|
Provides CRUD operations for park reviews nested under parks/{slug}/reviews/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.db.models import Avg
|
||||||
|
from django.utils import timezone
|
||||||
|
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.exceptions import NotFound, ValidationError
|
||||||
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
|
from apps.api.v1.serializers.park_reviews import (
|
||||||
|
ParkReviewCreateInputSerializer,
|
||||||
|
ParkReviewListOutputSerializer,
|
||||||
|
ParkReviewOutputSerializer,
|
||||||
|
ParkReviewStatsOutputSerializer,
|
||||||
|
ParkReviewUpdateInputSerializer,
|
||||||
|
)
|
||||||
|
from apps.parks.models import Park, ParkReview
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
list=extend_schema(
|
||||||
|
summary="List park reviews",
|
||||||
|
tags=["Park Reviews"],
|
||||||
|
),
|
||||||
|
create=extend_schema(
|
||||||
|
summary="Create park review",
|
||||||
|
tags=["Park Reviews"],
|
||||||
|
),
|
||||||
|
retrieve=extend_schema(
|
||||||
|
summary="Get park review details",
|
||||||
|
tags=["Park Reviews"],
|
||||||
|
),
|
||||||
|
update=extend_schema(
|
||||||
|
summary="Update park review",
|
||||||
|
tags=["Park Reviews"],
|
||||||
|
),
|
||||||
|
partial_update=extend_schema(
|
||||||
|
summary="Partially update park review",
|
||||||
|
tags=["Park Reviews"],
|
||||||
|
),
|
||||||
|
destroy=extend_schema(
|
||||||
|
summary="Delete park review",
|
||||||
|
tags=["Park Reviews"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class ParkReviewViewSet(ModelViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for managing park reviews with full CRUD operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
lookup_field = "id"
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
"""Set permissions based on action."""
|
||||||
|
permission_classes = [AllowAny] if self.action in ['list', 'retrieve', 'stats'] else [IsAuthenticated]
|
||||||
|
return [permission() for permission in permission_classes]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Get reviews for the current park."""
|
||||||
|
queryset = ParkReview.objects.select_related(
|
||||||
|
"park", "user", "user__profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
park_slug = self.kwargs.get("park_slug")
|
||||||
|
if park_slug:
|
||||||
|
try:
|
||||||
|
park, _ = Park.get_by_slug(park_slug)
|
||||||
|
queryset = queryset.filter(park=park)
|
||||||
|
except Park.DoesNotExist:
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
if not (hasattr(self.request, 'user') and getattr(self.request.user, 'is_staff', False)):
|
||||||
|
queryset = queryset.filter(is_published=True)
|
||||||
|
|
||||||
|
return queryset.order_by("-created_at")
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == "list":
|
||||||
|
return ParkReviewListOutputSerializer
|
||||||
|
elif self.action == "create":
|
||||||
|
return ParkReviewCreateInputSerializer
|
||||||
|
elif self.action in ["update", "partial_update"]:
|
||||||
|
return ParkReviewUpdateInputSerializer
|
||||||
|
else:
|
||||||
|
return ParkReviewOutputSerializer
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
park_slug = self.kwargs.get("park_slug")
|
||||||
|
try:
|
||||||
|
park, _ = Park.get_by_slug(park_slug)
|
||||||
|
except Park.DoesNotExist:
|
||||||
|
raise NotFound("Park not found")
|
||||||
|
|
||||||
|
if ParkReview.objects.filter(park=park, user=self.request.user).exists():
|
||||||
|
raise ValidationError("You have already reviewed this park")
|
||||||
|
|
||||||
|
serializer.save(
|
||||||
|
park=park,
|
||||||
|
user=self.request.user,
|
||||||
|
is_published=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
instance = self.get_object()
|
||||||
|
if not (self.request.user == instance.user or getattr(self.request.user, "is_staff", False)):
|
||||||
|
raise PermissionDenied("You can only edit your own reviews.")
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
if not (self.request.user == instance.user or getattr(self.request.user, "is_staff", False)):
|
||||||
|
raise PermissionDenied("You can only delete your own reviews.")
|
||||||
|
instance.delete()
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary="Get park review statistics",
|
||||||
|
responses={200: ParkReviewStatsOutputSerializer},
|
||||||
|
tags=["Park Reviews"],
|
||||||
|
)
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def stats(self, request, park_slug=None):
|
||||||
|
try:
|
||||||
|
park, _ = Park.get_by_slug(park_slug)
|
||||||
|
except Park.DoesNotExist:
|
||||||
|
return Response({"error": "Park not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
reviews = ParkReview.objects.filter(park=park, is_published=True)
|
||||||
|
total_reviews = reviews.count()
|
||||||
|
avg_rating = reviews.aggregate(avg=Avg('rating'))['avg']
|
||||||
|
|
||||||
|
rating_distribution = {}
|
||||||
|
for i in range(1, 11):
|
||||||
|
rating_distribution[str(i)] = reviews.filter(rating=i).count()
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
recent_reviews = reviews.filter(created_at__gte=timezone.now() - timedelta(days=30)).count()
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"total_reviews": total_reviews,
|
||||||
|
"published_reviews": total_reviews,
|
||||||
|
"pending_reviews": ParkReview.objects.filter(park=park, is_published=False).count(),
|
||||||
|
"average_rating": avg_rating,
|
||||||
|
"rating_distribution": rating_distribution,
|
||||||
|
"recent_reviews": recent_reviews,
|
||||||
|
}
|
||||||
|
return Response(ParkReviewStatsOutputSerializer(stats).data)
|
||||||
@@ -6,19 +6,16 @@ This module implements endpoints for accessing rides within specific parks:
|
|||||||
- GET /parks/{park_slug}/rides/{ride_slug}/ - Get specific ride details within park context
|
- GET /parks/{park_slug}/rides/{ride_slug}/ - Get specific ride details within park context
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any
|
from django.db.models import Q
|
||||||
from django.db import models
|
|
||||||
from django.db.models import Q, Count, Avg
|
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from rest_framework import status, permissions
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
from rest_framework.views import APIView
|
from rest_framework import permissions, status
|
||||||
|
from rest_framework.exceptions import NotFound
|
||||||
|
from rest_framework.pagination import PageNumberPagination
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.pagination import PageNumberPagination
|
from rest_framework.views import APIView
|
||||||
from rest_framework.exceptions import NotFound
|
|
||||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
|
||||||
from drf_spectacular.types import OpenApiTypes
|
|
||||||
|
|
||||||
# Import models
|
# Import models
|
||||||
try:
|
try:
|
||||||
@@ -32,8 +29,8 @@ except Exception:
|
|||||||
|
|
||||||
# Import serializers
|
# Import serializers
|
||||||
try:
|
try:
|
||||||
from apps.api.v1.serializers.rides import RideListOutputSerializer, RideDetailOutputSerializer
|
|
||||||
from apps.api.v1.serializers.parks import ParkDetailOutputSerializer
|
from apps.api.v1.serializers.parks import ParkDetailOutputSerializer
|
||||||
|
from apps.api.v1.serializers.rides import RideDetailOutputSerializer, RideListOutputSerializer
|
||||||
SERIALIZERS_AVAILABLE = True
|
SERIALIZERS_AVAILABLE = True
|
||||||
except Exception:
|
except Exception:
|
||||||
SERIALIZERS_AVAILABLE = False
|
SERIALIZERS_AVAILABLE = False
|
||||||
|
|||||||
@@ -11,23 +11,24 @@ This module implements comprehensive park endpoints with full filtering support:
|
|||||||
Supports all 24 filtering parameters from frontend API documentation.
|
Supports all 24 filtering parameters from frontend API documentation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from django.db import models
|
|
||||||
from django.db.models import Q, Count, Avg
|
|
||||||
from django.db.models.query import QuerySet
|
|
||||||
|
|
||||||
from rest_framework import status, permissions
|
from django.db import models
|
||||||
from rest_framework.views import APIView
|
from django.db.models import Avg, Count, Q
|
||||||
|
from django.db.models.query import QuerySet
|
||||||
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
|
from rest_framework import permissions, status
|
||||||
|
from rest_framework.exceptions import NotFound
|
||||||
|
from rest_framework.pagination import PageNumberPagination
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.pagination import PageNumberPagination
|
from rest_framework.views import APIView
|
||||||
from rest_framework.exceptions import NotFound
|
|
||||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
|
||||||
from drf_spectacular.types import OpenApiTypes
|
|
||||||
|
|
||||||
# Import models
|
# Import models
|
||||||
try:
|
try:
|
||||||
from apps.parks.models import Park, Company
|
from apps.parks.models import Company, Park
|
||||||
MODELS_AVAILABLE = True
|
MODELS_AVAILABLE = True
|
||||||
except Exception:
|
except Exception:
|
||||||
Park = None # type: ignore
|
Park = None # type: ignore
|
||||||
@@ -45,11 +46,11 @@ except Exception:
|
|||||||
# Import serializers
|
# Import serializers
|
||||||
try:
|
try:
|
||||||
from apps.api.v1.serializers.parks import (
|
from apps.api.v1.serializers.parks import (
|
||||||
ParkListOutputSerializer,
|
|
||||||
ParkDetailOutputSerializer,
|
|
||||||
ParkCreateInputSerializer,
|
ParkCreateInputSerializer,
|
||||||
ParkUpdateInputSerializer,
|
ParkDetailOutputSerializer,
|
||||||
ParkImageSettingsInputSerializer,
|
ParkImageSettingsInputSerializer,
|
||||||
|
ParkListOutputSerializer,
|
||||||
|
ParkUpdateInputSerializer,
|
||||||
)
|
)
|
||||||
SERIALIZERS_AVAILABLE = True
|
SERIALIZERS_AVAILABLE = True
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -287,17 +288,13 @@ class ParkListCreateAPIView(APIView):
|
|||||||
"""Apply rating-based filtering to the queryset."""
|
"""Apply rating-based filtering to the queryset."""
|
||||||
min_rating = params.get("min_rating")
|
min_rating = params.get("min_rating")
|
||||||
if min_rating:
|
if min_rating:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
qs = qs.filter(average_rating__gte=float(min_rating))
|
qs = qs.filter(average_rating__gte=float(min_rating))
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
max_rating = params.get("max_rating")
|
max_rating = params.get("max_rating")
|
||||||
if max_rating:
|
if max_rating:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
qs = qs.filter(average_rating__lte=float(max_rating))
|
qs = qs.filter(average_rating__lte=float(max_rating))
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
@@ -305,17 +302,13 @@ class ParkListCreateAPIView(APIView):
|
|||||||
"""Apply ride count filtering to the queryset."""
|
"""Apply ride count filtering to the queryset."""
|
||||||
min_ride_count = params.get("min_ride_count")
|
min_ride_count = params.get("min_ride_count")
|
||||||
if min_ride_count:
|
if min_ride_count:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
qs = qs.filter(ride_count__gte=int(min_ride_count))
|
qs = qs.filter(ride_count__gte=int(min_ride_count))
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
max_ride_count = params.get("max_ride_count")
|
max_ride_count = params.get("max_ride_count")
|
||||||
if max_ride_count:
|
if max_ride_count:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
qs = qs.filter(ride_count__lte=int(max_ride_count))
|
qs = qs.filter(ride_count__lte=int(max_ride_count))
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
@@ -323,24 +316,18 @@ class ParkListCreateAPIView(APIView):
|
|||||||
"""Apply opening year filtering to the queryset."""
|
"""Apply opening year filtering to the queryset."""
|
||||||
opening_year = params.get("opening_year")
|
opening_year = params.get("opening_year")
|
||||||
if opening_year:
|
if opening_year:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
qs = qs.filter(opening_date__year=int(opening_year))
|
qs = qs.filter(opening_date__year=int(opening_year))
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
min_opening_year = params.get("min_opening_year")
|
min_opening_year = params.get("min_opening_year")
|
||||||
if min_opening_year:
|
if min_opening_year:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
qs = qs.filter(opening_date__year__gte=int(min_opening_year))
|
qs = qs.filter(opening_date__year__gte=int(min_opening_year))
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
max_opening_year = params.get("max_opening_year")
|
max_opening_year = params.get("max_opening_year")
|
||||||
if max_opening_year:
|
if max_opening_year:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
qs = qs.filter(opening_date__year__lte=int(max_opening_year))
|
qs = qs.filter(opening_date__year__lte=int(max_opening_year))
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
@@ -355,17 +342,13 @@ class ParkListCreateAPIView(APIView):
|
|||||||
|
|
||||||
min_roller_coaster_count = params.get("min_roller_coaster_count")
|
min_roller_coaster_count = params.get("min_roller_coaster_count")
|
||||||
if min_roller_coaster_count:
|
if min_roller_coaster_count:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
qs = qs.filter(coaster_count__gte=int(min_roller_coaster_count))
|
qs = qs.filter(coaster_count__gte=int(min_roller_coaster_count))
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
max_roller_coaster_count = params.get("max_roller_coaster_count")
|
max_roller_coaster_count = params.get("max_roller_coaster_count")
|
||||||
if max_roller_coaster_count:
|
if max_roller_coaster_count:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
qs = qs.filter(coaster_count__lte=int(max_roller_coaster_count))
|
qs = qs.filter(coaster_count__lte=int(max_roller_coaster_count))
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
@@ -440,11 +423,11 @@ class ParkDetailAPIView(APIView):
|
|||||||
def _get_park_or_404(self, identifier: str) -> Any:
|
def _get_park_or_404(self, identifier: str) -> Any:
|
||||||
if not MODELS_AVAILABLE:
|
if not MODELS_AVAILABLE:
|
||||||
raise NotFound(
|
raise NotFound(
|
||||||
(
|
|
||||||
"Park detail is not available because domain models "
|
"Park detail is not available because domain models "
|
||||||
"are not imported. Implement apps.parks.models.Park "
|
"are not imported. Implement apps.parks.models.Park "
|
||||||
"to enable detail endpoints."
|
"to enable detail endpoints."
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try to parse as integer ID first
|
# Try to parse as integer ID first
|
||||||
@@ -1081,3 +1064,45 @@ class ParkImageSettingsAPIView(APIView):
|
|||||||
park, context={"request": request}
|
park, context={"request": request}
|
||||||
)
|
)
|
||||||
return Response(output_serializer.data)
|
return Response(output_serializer.data)
|
||||||
|
# --- Operator list ----------------------------------------------------------
|
||||||
|
@extend_schema(
|
||||||
|
summary="List park operators",
|
||||||
|
description="List all companies with OPERATOR role, including park counts.",
|
||||||
|
responses={
|
||||||
|
200: OpenApiTypes.OBJECT,
|
||||||
|
},
|
||||||
|
tags=["Parks"],
|
||||||
|
)
|
||||||
|
class OperatorListAPIView(APIView):
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get(self, request: Request) -> Response:
|
||||||
|
if not MODELS_AVAILABLE:
|
||||||
|
return Response(
|
||||||
|
{"detail": "Models not available"},
|
||||||
|
status=status.HTTP_501_NOT_IMPLEMENTED
|
||||||
|
)
|
||||||
|
|
||||||
|
operators = (
|
||||||
|
Company.objects.filter(roles__contains=["OPERATOR"])
|
||||||
|
.annotate(park_count=Count("operated_parks"))
|
||||||
|
.only("id", "name", "slug", "roles", "description")
|
||||||
|
.order_by("name")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simple serialization
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"id": op.id,
|
||||||
|
"name": op.name,
|
||||||
|
"slug": op.slug,
|
||||||
|
"description": op.description,
|
||||||
|
"park_count": op.park_count,
|
||||||
|
}
|
||||||
|
for op in operators
|
||||||
|
]
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
"results": data,
|
||||||
|
"count": len(data)
|
||||||
|
})
|
||||||
|
|||||||
@@ -13,27 +13,27 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from drf_spectacular.utils import extend_schema_view, extend_schema
|
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import ValidationError, NotFound
|
from rest_framework.exceptions import NotFound, ValidationError
|
||||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from apps.rides.models.media import RidePhoto
|
|
||||||
from apps.rides.models import Ride
|
|
||||||
from apps.parks.models import Park
|
|
||||||
from apps.rides.services.media_service import RideMediaService
|
|
||||||
from apps.api.v1.rides.serializers import (
|
from apps.api.v1.rides.serializers import (
|
||||||
RidePhotoOutputSerializer,
|
|
||||||
RidePhotoCreateInputSerializer,
|
|
||||||
RidePhotoUpdateInputSerializer,
|
|
||||||
RidePhotoListOutputSerializer,
|
|
||||||
RidePhotoApprovalInputSerializer,
|
RidePhotoApprovalInputSerializer,
|
||||||
|
RidePhotoCreateInputSerializer,
|
||||||
|
RidePhotoListOutputSerializer,
|
||||||
|
RidePhotoOutputSerializer,
|
||||||
RidePhotoStatsOutputSerializer,
|
RidePhotoStatsOutputSerializer,
|
||||||
|
RidePhotoUpdateInputSerializer,
|
||||||
)
|
)
|
||||||
|
from apps.parks.models import Park
|
||||||
|
from apps.rides.models import Ride
|
||||||
|
from apps.rides.models.media import RidePhoto
|
||||||
|
from apps.rides.services.media_service import RideMediaService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -116,10 +116,7 @@ class RidePhotoViewSet(ModelViewSet):
|
|||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
"""Set permissions based on action."""
|
"""Set permissions based on action."""
|
||||||
if self.action in ['list', 'retrieve', 'stats']:
|
permission_classes = [AllowAny] if self.action in ['list', 'retrieve', 'stats'] else [IsAuthenticated]
|
||||||
permission_classes = [AllowAny]
|
|
||||||
else:
|
|
||||||
permission_classes = [IsAuthenticated]
|
|
||||||
return [permission() for permission in permission_classes]
|
return [permission() for permission in permission_classes]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
|||||||
@@ -12,28 +12,28 @@ if TYPE_CHECKING:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db.models import Avg, Count, Q
|
from django.db.models import Avg
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from drf_spectacular.utils import extend_schema_view, extend_schema
|
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import ValidationError, NotFound
|
from rest_framework.exceptions import NotFound, ValidationError
|
||||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from apps.rides.models.reviews import RideReview
|
|
||||||
from apps.rides.models import Ride
|
|
||||||
from apps.parks.models import Park
|
|
||||||
from apps.api.v1.serializers.ride_reviews import (
|
from apps.api.v1.serializers.ride_reviews import (
|
||||||
RideReviewOutputSerializer,
|
|
||||||
RideReviewCreateInputSerializer,
|
RideReviewCreateInputSerializer,
|
||||||
RideReviewUpdateInputSerializer,
|
|
||||||
RideReviewListOutputSerializer,
|
RideReviewListOutputSerializer,
|
||||||
RideReviewStatsOutputSerializer,
|
|
||||||
RideReviewModerationInputSerializer,
|
RideReviewModerationInputSerializer,
|
||||||
|
RideReviewOutputSerializer,
|
||||||
|
RideReviewStatsOutputSerializer,
|
||||||
|
RideReviewUpdateInputSerializer,
|
||||||
)
|
)
|
||||||
|
from apps.parks.models import Park
|
||||||
|
from apps.rides.models import Ride
|
||||||
|
from apps.rides.models.reviews import RideReview
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -115,10 +115,7 @@ class RideReviewViewSet(ModelViewSet):
|
|||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
"""Set permissions based on action."""
|
"""Set permissions based on action."""
|
||||||
if self.action in ['list', 'retrieve', 'stats']:
|
permission_classes = [AllowAny] if self.action in ['list', 'retrieve', 'stats'] else [IsAuthenticated]
|
||||||
permission_classes = [AllowAny]
|
|
||||||
else:
|
|
||||||
permission_classes = [IsAuthenticated]
|
|
||||||
return [permission() for permission in permission_classes]
|
return [permission() for permission in permission_classes]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ This module contains serializers for park-specific media functionality.
|
|||||||
Enhanced from rogue implementation to maintain full feature parity.
|
Enhanced from rogue implementation to maintain full feature parity.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from rest_framework import serializers
|
|
||||||
from drf_spectacular.utils import (
|
from drf_spectacular.utils import (
|
||||||
|
OpenApiExample,
|
||||||
extend_schema_field,
|
extend_schema_field,
|
||||||
extend_schema_serializer,
|
extend_schema_serializer,
|
||||||
OpenApiExample,
|
|
||||||
)
|
)
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
from apps.parks.models import Park, ParkPhoto
|
from apps.parks.models import Park, ParkPhoto
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,25 +6,34 @@ intentionally expansive to match the rides API functionality and provide
|
|||||||
complete feature parity for parks management.
|
complete feature parity for parks management.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.urls import path, include
|
from django.urls import include, path
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from .park_views import (
|
from apps.parks.views import location_search, reverse_geocode
|
||||||
ParkListCreateAPIView,
|
from apps.parks.views_roadtrip import (
|
||||||
ParkDetailAPIView,
|
CreateTripView,
|
||||||
FilterOptionsAPIView,
|
FindParksAlongRouteView,
|
||||||
CompanySearchAPIView,
|
GeocodeAddressView,
|
||||||
ParkSearchSuggestionsAPIView,
|
ParkDistanceCalculatorView,
|
||||||
ParkImageSettingsAPIView,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from .park_rides_views import (
|
from .park_rides_views import (
|
||||||
ParkRidesListAPIView,
|
|
||||||
ParkRideDetailAPIView,
|
|
||||||
ParkComprehensiveDetailAPIView,
|
ParkComprehensiveDetailAPIView,
|
||||||
|
ParkRideDetailAPIView,
|
||||||
|
ParkRidesListAPIView,
|
||||||
|
)
|
||||||
|
from .park_views import (
|
||||||
|
CompanySearchAPIView,
|
||||||
|
FilterOptionsAPIView,
|
||||||
|
OperatorListAPIView,
|
||||||
|
ParkDetailAPIView,
|
||||||
|
ParkImageSettingsAPIView,
|
||||||
|
ParkListCreateAPIView,
|
||||||
|
ParkSearchSuggestionsAPIView,
|
||||||
)
|
)
|
||||||
from .views import ParkPhotoViewSet, HybridParkAPIView, ParkFilterMetadataAPIView
|
|
||||||
from .ride_photos_views import RidePhotoViewSet
|
from .ride_photos_views import RidePhotoViewSet
|
||||||
from .ride_reviews_views import RideReviewViewSet
|
from .ride_reviews_views import RideReviewViewSet
|
||||||
|
from .views import HybridParkAPIView, ParkFilterMetadataAPIView, ParkPhotoViewSet
|
||||||
|
|
||||||
# Create router for nested photo endpoints
|
# Create router for nested photo endpoints
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
@@ -37,6 +46,14 @@ ride_photos_router.register(r"", RidePhotoViewSet, basename="ride-photo")
|
|||||||
ride_reviews_router = DefaultRouter()
|
ride_reviews_router = DefaultRouter()
|
||||||
ride_reviews_router.register(r"", RideReviewViewSet, basename="ride-review")
|
ride_reviews_router.register(r"", RideReviewViewSet, basename="ride-review")
|
||||||
|
|
||||||
|
from .history_views import ParkHistoryViewSet, RideHistoryViewSet
|
||||||
|
from .park_reviews_views import ParkReviewViewSet
|
||||||
|
|
||||||
|
# Create routers for nested park endpoints
|
||||||
|
reviews_router = DefaultRouter()
|
||||||
|
reviews_router.register(r"", ParkReviewViewSet, basename="park-review")
|
||||||
|
|
||||||
|
|
||||||
app_name = "api_v1_parks"
|
app_name = "api_v1_parks"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@@ -77,11 +94,35 @@ urlpatterns = [
|
|||||||
name="park-image-settings",
|
name="park-image-settings",
|
||||||
),
|
),
|
||||||
# Park photo endpoints - domain-specific photo management
|
# Park photo endpoints - domain-specific photo management
|
||||||
path("<int:park_pk>/photos/", include(router.urls)),
|
path("<str:park_pk>/photos/", include(router.urls)),
|
||||||
|
|
||||||
# Nested ride photo endpoints - photos for specific rides within parks
|
# Nested ride photo endpoints - photos for specific rides within parks
|
||||||
path("<str:park_slug>/rides/<str:ride_slug>/photos/", include(ride_photos_router.urls)),
|
path("<str:park_slug>/rides/<str:ride_slug>/photos/", include(ride_photos_router.urls)),
|
||||||
|
|
||||||
# Nested ride review endpoints - reviews for specific rides within parks
|
# Nested ride review endpoints - reviews for specific rides within parks
|
||||||
path("<str:park_slug>/rides/<str:ride_slug>/reviews/", include(ride_reviews_router.urls)),
|
path("<str:park_slug>/rides/<str:ride_slug>/reviews/", include(ride_reviews_router.urls)),
|
||||||
|
# Nested ride review endpoints - reviews for specific rides within parks
|
||||||
|
path("<str:park_slug>/rides/<str:ride_slug>/reviews/", include(ride_reviews_router.urls)),
|
||||||
|
|
||||||
|
# Ride History
|
||||||
|
path("<str:park_slug>/rides/<str:ride_slug>/history/", RideHistoryViewSet.as_view({'get': 'list'}), name="ride-history"),
|
||||||
|
|
||||||
|
# Park Reviews
|
||||||
|
path("<str:park_slug>/reviews/", include(reviews_router.urls)),
|
||||||
|
|
||||||
|
# Park History
|
||||||
|
path("<str:park_slug>/history/", ParkHistoryViewSet.as_view({'get': 'list'}), name="park-history"),
|
||||||
|
|
||||||
|
# Roadtrip API endpoints
|
||||||
|
path("roadtrip/create/", CreateTripView.as_view(), name="roadtrip-create"),
|
||||||
|
path("roadtrip/find-along-route/", FindParksAlongRouteView.as_view(), name="roadtrip-find"),
|
||||||
|
path("roadtrip/geocode/", GeocodeAddressView.as_view(), name="roadtrip-geocode"),
|
||||||
|
path("roadtrip/distance/", ParkDistanceCalculatorView.as_view(), name="roadtrip-distance"),
|
||||||
|
|
||||||
|
# Operator endpoints
|
||||||
|
path("operators/", OperatorListAPIView.as_view(), name="operator-list"),
|
||||||
|
|
||||||
|
# Location search endpoints
|
||||||
|
path("search/location/", location_search, name="location-search"),
|
||||||
|
path("search/reverse-geocode/", reverse_geocode, name="reverse-geocode"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,36 +3,52 @@ Park API views for ThrillWiki API v1.
|
|||||||
|
|
||||||
This module contains consolidated park photo viewset for the centralized API structure.
|
This module contains consolidated park photo viewset for the centralized API structure.
|
||||||
Enhanced from rogue implementation to maintain full feature parity.
|
Enhanced from rogue implementation to maintain full feature parity.
|
||||||
|
|
||||||
|
Caching Strategy:
|
||||||
|
- HybridParkAPIView: 10 minutes (600s) - park lists are queried frequently
|
||||||
|
- ParkFilterMetadataAPIView: 30 minutes (1800s) - filter metadata is stable
|
||||||
|
- ParkPhotoViewSet.list/retrieve: 5 minutes (300s) - photos may be updated
|
||||||
|
- ParkPhotoViewSet.stats: 10 minutes (600s) - stats are aggregated
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .serializers import (
|
|
||||||
ParkPhotoOutputSerializer,
|
|
||||||
ParkPhotoCreateInputSerializer,
|
|
||||||
ParkPhotoUpdateInputSerializer,
|
|
||||||
ParkPhotoListOutputSerializer,
|
|
||||||
ParkPhotoApprovalInputSerializer,
|
|
||||||
ParkPhotoStatsOutputSerializer,
|
|
||||||
)
|
|
||||||
from typing import Any, cast
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from drf_spectacular.utils import extend_schema_view, extend_schema, OpenApiParameter
|
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from apps.parks.models import ParkPhoto, Park
|
from apps.core.decorators.cache_decorators import cache_api_response
|
||||||
|
from apps.core.exceptions import (
|
||||||
|
NotFoundError,
|
||||||
|
ServiceError,
|
||||||
|
ValidationException,
|
||||||
|
)
|
||||||
|
from apps.core.utils.error_handling import ErrorHandler
|
||||||
|
from apps.parks.models import Park, ParkPhoto
|
||||||
from apps.parks.services import ParkMediaService
|
from apps.parks.services import ParkMediaService
|
||||||
from django.contrib.auth import get_user_model
|
from apps.parks.services.hybrid_loader import smart_park_loader
|
||||||
|
|
||||||
UserModel = get_user_model()
|
from .serializers import (
|
||||||
|
HybridParkSerializer,
|
||||||
|
ParkPhotoApprovalInputSerializer,
|
||||||
|
ParkPhotoCreateInputSerializer,
|
||||||
|
ParkPhotoListOutputSerializer,
|
||||||
|
ParkPhotoOutputSerializer,
|
||||||
|
ParkPhotoStatsOutputSerializer,
|
||||||
|
ParkPhotoUpdateInputSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
UserModel = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
@@ -113,10 +129,7 @@ class ParkPhotoViewSet(ModelViewSet):
|
|||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
"""Set permissions based on action."""
|
"""Set permissions based on action."""
|
||||||
if self.action in ['list', 'retrieve', 'stats']:
|
permission_classes = [AllowAny] if self.action in ["list", "retrieve", "stats"] else [IsAuthenticated]
|
||||||
permission_classes = [AllowAny]
|
|
||||||
else:
|
|
||||||
permission_classes = [IsAuthenticated]
|
|
||||||
return [permission() for permission in permission_classes]
|
return [permission() for permission in permission_classes]
|
||||||
|
|
||||||
def get_queryset(self): # type: ignore[override]
|
def get_queryset(self): # type: ignore[override]
|
||||||
@@ -125,10 +138,14 @@ class ParkPhotoViewSet(ModelViewSet):
|
|||||||
"park", "park__operator", "uploaded_by"
|
"park", "park__operator", "uploaded_by"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# If park_pk is provided in URL kwargs, filter by park
|
||||||
# If park_pk is provided in URL kwargs, filter by park
|
# If park_pk is provided in URL kwargs, filter by park
|
||||||
park_pk = self.kwargs.get("park_pk")
|
park_pk = self.kwargs.get("park_pk")
|
||||||
if park_pk:
|
if park_pk:
|
||||||
queryset = queryset.filter(park_id=park_pk)
|
if str(park_pk).isdigit():
|
||||||
|
queryset = queryset.filter(park_id=park_pk)
|
||||||
|
else:
|
||||||
|
queryset = queryset.filter(park__slug=park_pk)
|
||||||
|
|
||||||
return queryset.order_by("-created_at")
|
return queryset.order_by("-created_at")
|
||||||
|
|
||||||
@@ -147,10 +164,13 @@ class ParkPhotoViewSet(ModelViewSet):
|
|||||||
"""Create a new park photo using ParkMediaService."""
|
"""Create a new park photo using ParkMediaService."""
|
||||||
park_id = self.kwargs.get("park_pk")
|
park_id = self.kwargs.get("park_pk")
|
||||||
if not park_id:
|
if not park_id:
|
||||||
raise ValidationError("Park ID is required")
|
raise ValidationError("Park ID/Slug is required")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
Park.objects.get(pk=park_id)
|
park = Park.objects.get(pk=park_id) if str(park_id).isdigit() else Park.objects.get(slug=park_id)
|
||||||
|
|
||||||
|
# Use real park ID
|
||||||
|
park_id = park.id
|
||||||
except Park.DoesNotExist:
|
except Park.DoesNotExist:
|
||||||
raise ValidationError("Park not found")
|
raise ValidationError("Park not found")
|
||||||
|
|
||||||
@@ -166,8 +186,11 @@ class ParkPhotoViewSet(ModelViewSet):
|
|||||||
# Set the instance for the serializer response
|
# Set the instance for the serializer response
|
||||||
serializer.instance = photo
|
serializer.instance = photo
|
||||||
|
|
||||||
except Exception as e:
|
except (ValidationException, ValidationError) as e:
|
||||||
logger.error(f"Error creating park photo: {e}")
|
logger.warning(f"Validation error creating park photo: {e}")
|
||||||
|
raise ValidationError(str(e))
|
||||||
|
except ServiceError as e:
|
||||||
|
logger.error(f"Service error creating park photo: {e}")
|
||||||
raise ValidationError(f"Failed to create photo: {str(e)}")
|
raise ValidationError(f"Failed to create photo: {str(e)}")
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
@@ -190,8 +213,11 @@ class ParkPhotoViewSet(ModelViewSet):
|
|||||||
# Remove is_primary from validated_data since service handles it
|
# Remove is_primary from validated_data since service handles it
|
||||||
if "is_primary" in serializer.validated_data:
|
if "is_primary" in serializer.validated_data:
|
||||||
del serializer.validated_data["is_primary"]
|
del serializer.validated_data["is_primary"]
|
||||||
except Exception as e:
|
except (ValidationException, ValidationError) as e:
|
||||||
logger.error(f"Error setting primary photo: {e}")
|
logger.warning(f"Validation error setting primary photo: {e}")
|
||||||
|
raise ValidationError(str(e))
|
||||||
|
except ServiceError as e:
|
||||||
|
logger.error(f"Service error setting primary photo: {e}")
|
||||||
raise ValidationError(f"Failed to set primary photo: {str(e)}")
|
raise ValidationError(f"Failed to set primary photo: {str(e)}")
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
def perform_destroy(self, instance):
|
||||||
@@ -205,25 +231,30 @@ class ParkPhotoViewSet(ModelViewSet):
|
|||||||
"You can only delete your own photos or be an admin."
|
"You can only delete your own photos or be an admin."
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
# Delete from Cloudflare first if image exists
|
||||||
# Delete from Cloudflare first if image exists
|
if instance.image:
|
||||||
if instance.image:
|
try:
|
||||||
try:
|
from django_cloudflareimages_toolkit.services import (
|
||||||
from django_cloudflareimages_toolkit.services import CloudflareImagesService
|
CloudflareImagesService,
|
||||||
service = CloudflareImagesService()
|
)
|
||||||
service.delete_image(instance.image)
|
|
||||||
logger.info(
|
|
||||||
f"Successfully deleted park photo from Cloudflare: {instance.image.cloudflare_id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Failed to delete park photo from Cloudflare: {str(e)}")
|
|
||||||
# Continue with database deletion even if Cloudflare deletion fails
|
|
||||||
|
|
||||||
|
service = CloudflareImagesService()
|
||||||
|
service.delete_image(instance.image)
|
||||||
|
logger.info(
|
||||||
|
f"Successfully deleted park photo from Cloudflare: {instance.image.cloudflare_id}"
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("CloudflareImagesService not available")
|
||||||
|
except ServiceError as e:
|
||||||
|
logger.error(f"Service error deleting from Cloudflare: {str(e)}")
|
||||||
|
# Continue with database deletion even if Cloudflare deletion fails
|
||||||
|
|
||||||
|
try:
|
||||||
ParkMediaService().delete_photo(
|
ParkMediaService().delete_photo(
|
||||||
instance.id, deleted_by=cast(UserModel, self.request.user)
|
instance.id, deleted_by=cast(UserModel, self.request.user)
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except ServiceError as e:
|
||||||
logger.error(f"Error deleting park photo: {e}")
|
logger.error(f"Service error deleting park photo: {e}")
|
||||||
raise ValidationError(f"Failed to delete photo: {str(e)}")
|
raise ValidationError(f"Failed to delete photo: {str(e)}")
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
@@ -265,11 +296,18 @@ class ParkPhotoViewSet(ModelViewSet):
|
|||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except (ValidationException, ValidationError) as e:
|
||||||
logger.error(f"Error setting primary photo: {e}")
|
logger.warning(f"Validation error setting primary photo: {e}")
|
||||||
return Response(
|
return ErrorHandler.handle_api_error(
|
||||||
{"error": f"Failed to set primary photo: {str(e)}"},
|
e,
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
user_message="Failed to set primary photo",
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
except ServiceError as e:
|
||||||
|
return ErrorHandler.handle_api_error(
|
||||||
|
e,
|
||||||
|
user_message="Failed to set primary photo",
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
@@ -307,7 +345,10 @@ class ParkPhotoViewSet(ModelViewSet):
|
|||||||
# Filter photos to only those belonging to this park (if park_pk provided)
|
# Filter photos to only those belonging to this park (if park_pk provided)
|
||||||
photos_queryset = ParkPhoto.objects.filter(id__in=photo_ids)
|
photos_queryset = ParkPhoto.objects.filter(id__in=photo_ids)
|
||||||
if park_id:
|
if park_id:
|
||||||
photos_queryset = photos_queryset.filter(park_id=park_id)
|
if str(park_id).isdigit():
|
||||||
|
photos_queryset = photos_queryset.filter(park_id=park_id)
|
||||||
|
else:
|
||||||
|
photos_queryset = photos_queryset.filter(park__slug=park_id)
|
||||||
|
|
||||||
updated_count = photos_queryset.update(is_approved=approve)
|
updated_count = photos_queryset.update(is_approved=approve)
|
||||||
|
|
||||||
@@ -319,11 +360,18 @@ class ParkPhotoViewSet(ModelViewSet):
|
|||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except (ValidationException, ValidationError) as e:
|
||||||
logger.error(f"Error in bulk photo approval: {e}")
|
logger.warning(f"Validation error in bulk photo approval: {e}")
|
||||||
return Response(
|
return ErrorHandler.handle_api_error(
|
||||||
{"error": f"Failed to update photos: {str(e)}"},
|
e,
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
user_message="Failed to update photos",
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
except ServiceError as e:
|
||||||
|
return ErrorHandler.handle_api_error(
|
||||||
|
e,
|
||||||
|
user_message="Failed to update photos",
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
@@ -343,11 +391,12 @@ class ParkPhotoViewSet(ModelViewSet):
|
|||||||
park = None
|
park = None
|
||||||
if park_pk:
|
if park_pk:
|
||||||
try:
|
try:
|
||||||
park = Park.objects.get(pk=park_pk)
|
park = Park.objects.get(pk=park_pk) if str(park_pk).isdigit() else Park.objects.get(slug=park_pk)
|
||||||
except Park.DoesNotExist:
|
except Park.DoesNotExist:
|
||||||
return Response(
|
return ErrorHandler.handle_api_error(
|
||||||
{"error": "Park not found."},
|
NotFoundError(f"Park with id/slug {park_pk} not found"),
|
||||||
status=status.HTTP_404_NOT_FOUND,
|
user_message="Park not found",
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -359,11 +408,11 @@ class ParkPhotoViewSet(ModelViewSet):
|
|||||||
|
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
except Exception as e:
|
except ServiceError as e:
|
||||||
logger.error(f"Error getting park photo stats: {e}")
|
return ErrorHandler.handle_api_error(
|
||||||
return Response(
|
e,
|
||||||
{"error": f"Failed to get photo statistics: {str(e)}"},
|
user_message="Failed to get photo statistics",
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Legacy compatibility action using the legacy set_primary logic
|
# Legacy compatibility action using the legacy set_primary logic
|
||||||
@@ -394,9 +443,19 @@ class ParkPhotoViewSet(ModelViewSet):
|
|||||||
park_id=photo.park_id, photo_id=photo.id
|
park_id=photo.park_id, photo_id=photo.id
|
||||||
)
|
)
|
||||||
return Response({"message": "Photo set as primary successfully."})
|
return Response({"message": "Photo set as primary successfully."})
|
||||||
except Exception as e:
|
except (ValidationException, ValidationError) as e:
|
||||||
logger.error(f"Error in set_primary_photo: {str(e)}", exc_info=True)
|
logger.warning(f"Validation error in set_primary_photo: {str(e)}")
|
||||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
return ErrorHandler.handle_api_error(
|
||||||
|
e,
|
||||||
|
user_message="Failed to set primary photo",
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
except ServiceError as e:
|
||||||
|
return ErrorHandler.handle_api_error(
|
||||||
|
e,
|
||||||
|
user_message="Failed to set primary photo",
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
summary="Save Cloudflare image as park photo",
|
summary="Save Cloudflare image as park photo",
|
||||||
@@ -421,7 +480,7 @@ class ParkPhotoViewSet(ModelViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
park = Park.objects.get(pk=park_pk)
|
park = Park.objects.get(pk=park_pk) if str(park_pk).isdigit() else Park.objects.get(slug=park_pk)
|
||||||
except Park.DoesNotExist:
|
except Park.DoesNotExist:
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Park not found"},
|
{"error": "Park not found"},
|
||||||
@@ -437,65 +496,60 @@ class ParkPhotoViewSet(ModelViewSet):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Import CloudflareImage model and service
|
# Import CloudflareImage model and service
|
||||||
|
from django.utils import timezone
|
||||||
from django_cloudflareimages_toolkit.models import CloudflareImage
|
from django_cloudflareimages_toolkit.models import CloudflareImage
|
||||||
from django_cloudflareimages_toolkit.services import CloudflareImagesService
|
from django_cloudflareimages_toolkit.services import CloudflareImagesService
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
# Always fetch the latest image data from Cloudflare API
|
# Always fetch the latest image data from Cloudflare API
|
||||||
|
# Get image details from Cloudflare API
|
||||||
|
service = CloudflareImagesService()
|
||||||
|
image_data = service.get_image(cloudflare_image_id)
|
||||||
|
|
||||||
|
if not image_data:
|
||||||
|
return ErrorHandler.handle_api_error(
|
||||||
|
NotFoundError("Image not found in Cloudflare"),
|
||||||
|
user_message="Image not found in Cloudflare",
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to find existing CloudflareImage record by cloudflare_id
|
||||||
|
cloudflare_image = None
|
||||||
try:
|
try:
|
||||||
# Get image details from Cloudflare API
|
cloudflare_image = CloudflareImage.objects.get(
|
||||||
service = CloudflareImagesService()
|
cloudflare_id=cloudflare_image_id
|
||||||
image_data = service.get_image(cloudflare_image_id)
|
)
|
||||||
|
|
||||||
if not image_data:
|
# Update existing record with latest data from Cloudflare
|
||||||
return Response(
|
cloudflare_image.status = "uploaded"
|
||||||
{"error": "Image not found in Cloudflare"},
|
cloudflare_image.uploaded_at = timezone.now()
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
cloudflare_image.metadata = image_data.get("meta", {})
|
||||||
)
|
# Extract variants from nested result structure
|
||||||
|
cloudflare_image.variants = image_data.get("result", {}).get(
|
||||||
|
"variants", []
|
||||||
|
)
|
||||||
|
cloudflare_image.cloudflare_metadata = image_data
|
||||||
|
cloudflare_image.width = image_data.get("width")
|
||||||
|
cloudflare_image.height = image_data.get("height")
|
||||||
|
cloudflare_image.format = image_data.get("format", "")
|
||||||
|
cloudflare_image.save()
|
||||||
|
|
||||||
# Try to find existing CloudflareImage record by cloudflare_id
|
except CloudflareImage.DoesNotExist:
|
||||||
cloudflare_image = None
|
# Create new CloudflareImage record from API response
|
||||||
try:
|
cloudflare_image = CloudflareImage.objects.create(
|
||||||
cloudflare_image = CloudflareImage.objects.get(
|
cloudflare_id=cloudflare_image_id,
|
||||||
cloudflare_id=cloudflare_image_id)
|
user=request.user,
|
||||||
|
status="uploaded",
|
||||||
# Update existing record with latest data from Cloudflare
|
upload_url="", # Not needed for uploaded images
|
||||||
cloudflare_image.status = 'uploaded'
|
expires_at=timezone.now()
|
||||||
cloudflare_image.uploaded_at = timezone.now()
|
+ timezone.timedelta(days=365), # Set far future expiry
|
||||||
cloudflare_image.metadata = image_data.get('meta', {})
|
uploaded_at=timezone.now(),
|
||||||
|
metadata=image_data.get("meta", {}),
|
||||||
# Extract variants from nested result structure
|
# Extract variants from nested result structure
|
||||||
cloudflare_image.variants = image_data.get(
|
variants=image_data.get("result", {}).get("variants", []),
|
||||||
'result', {}).get('variants', [])
|
cloudflare_metadata=image_data,
|
||||||
cloudflare_image.cloudflare_metadata = image_data
|
width=image_data.get("width"),
|
||||||
cloudflare_image.width = image_data.get('width')
|
height=image_data.get("height"),
|
||||||
cloudflare_image.height = image_data.get('height')
|
format=image_data.get("format", ""),
|
||||||
cloudflare_image.format = image_data.get('format', '')
|
|
||||||
cloudflare_image.save()
|
|
||||||
|
|
||||||
except CloudflareImage.DoesNotExist:
|
|
||||||
# Create new CloudflareImage record from API response
|
|
||||||
cloudflare_image = CloudflareImage.objects.create(
|
|
||||||
cloudflare_id=cloudflare_image_id,
|
|
||||||
user=request.user,
|
|
||||||
status='uploaded',
|
|
||||||
upload_url='', # Not needed for uploaded images
|
|
||||||
expires_at=timezone.now() + timezone.timedelta(days=365), # Set far future expiry
|
|
||||||
uploaded_at=timezone.now(),
|
|
||||||
metadata=image_data.get('meta', {}),
|
|
||||||
# Extract variants from nested result structure
|
|
||||||
variants=image_data.get('result', {}).get('variants', []),
|
|
||||||
cloudflare_metadata=image_data,
|
|
||||||
width=image_data.get('width'),
|
|
||||||
height=image_data.get('height'),
|
|
||||||
format=image_data.get('format', ''),
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as api_error:
|
|
||||||
logger.error(
|
|
||||||
f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True)
|
|
||||||
return Response(
|
|
||||||
{"error": f"Failed to fetch image from Cloudflare: {str(api_error)}"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create the park photo with the CloudflareImage reference
|
# Create the park photo with the CloudflareImage reference
|
||||||
@@ -516,25 +570,33 @@ class ParkPhotoViewSet(ModelViewSet):
|
|||||||
ParkMediaService().set_primary_photo(
|
ParkMediaService().set_primary_photo(
|
||||||
park_id=park.id, photo_id=photo.id
|
park_id=park.id, photo_id=photo.id
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except ServiceError as e:
|
||||||
logger.error(f"Error setting primary photo: {e}")
|
logger.error(f"Error setting primary photo: {e}")
|
||||||
# Don't fail the entire operation, just log the error
|
# Don't fail the entire operation, just log the error
|
||||||
|
|
||||||
serializer = ParkPhotoOutputSerializer(photo, context={"request": request})
|
serializer = ParkPhotoOutputSerializer(photo, context={"request": request})
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
except Exception as e:
|
except ImportError:
|
||||||
logger.error(f"Error saving park photo: {e}")
|
logger.error("CloudflareImagesService not available")
|
||||||
return Response(
|
return ErrorHandler.handle_api_error(
|
||||||
{"error": f"Failed to save photo: {str(e)}"},
|
ServiceError("Cloudflare Images service not available"),
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
user_message="Image upload service not available",
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
except (ValidationException, ValidationError) as e:
|
||||||
|
logger.warning(f"Validation error saving park photo: {e}")
|
||||||
|
return ErrorHandler.handle_api_error(
|
||||||
|
e,
|
||||||
|
user_message="Failed to save photo",
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
except ServiceError as e:
|
||||||
|
return ErrorHandler.handle_api_error(
|
||||||
|
e,
|
||||||
|
user_message="Failed to save photo",
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
from rest_framework.views import APIView
|
|
||||||
from rest_framework.permissions import AllowAny
|
|
||||||
from .serializers import HybridParkSerializer
|
|
||||||
from apps.parks.services.hybrid_loader import smart_park_loader
|
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
@@ -542,23 +604,79 @@ from apps.parks.services.hybrid_loader import smart_park_loader
|
|||||||
summary="Get parks with hybrid filtering",
|
summary="Get parks with hybrid filtering",
|
||||||
description="Retrieve parks with intelligent hybrid filtering strategy. Automatically chooses between client-side and server-side filtering based on data size.",
|
description="Retrieve parks with intelligent hybrid filtering strategy. Automatically chooses between client-side and server-side filtering based on data size.",
|
||||||
parameters=[
|
parameters=[
|
||||||
OpenApiParameter("status", OpenApiTypes.STR, description="Filter by park status (comma-separated for multiple)"),
|
OpenApiParameter(
|
||||||
OpenApiParameter("park_type", OpenApiTypes.STR, description="Filter by park type (comma-separated for multiple)"),
|
"status",
|
||||||
OpenApiParameter("country", OpenApiTypes.STR, description="Filter by country (comma-separated for multiple)"),
|
OpenApiTypes.STR,
|
||||||
OpenApiParameter("state", OpenApiTypes.STR, description="Filter by state (comma-separated for multiple)"),
|
description="Filter by park status (comma-separated for multiple)",
|
||||||
OpenApiParameter("opening_year_min", OpenApiTypes.INT, description="Minimum opening year"),
|
),
|
||||||
OpenApiParameter("opening_year_max", OpenApiTypes.INT, description="Maximum opening year"),
|
OpenApiParameter(
|
||||||
OpenApiParameter("size_min", OpenApiTypes.NUMBER, description="Minimum park size in acres"),
|
"park_type",
|
||||||
OpenApiParameter("size_max", OpenApiTypes.NUMBER, description="Maximum park size in acres"),
|
OpenApiTypes.STR,
|
||||||
OpenApiParameter("rating_min", OpenApiTypes.NUMBER, description="Minimum average rating"),
|
description="Filter by park type (comma-separated for multiple)",
|
||||||
OpenApiParameter("rating_max", OpenApiTypes.NUMBER, description="Maximum average rating"),
|
),
|
||||||
OpenApiParameter("ride_count_min", OpenApiTypes.INT, description="Minimum ride count"),
|
OpenApiParameter(
|
||||||
OpenApiParameter("ride_count_max", OpenApiTypes.INT, description="Maximum ride count"),
|
"country",
|
||||||
OpenApiParameter("coaster_count_min", OpenApiTypes.INT, description="Minimum coaster count"),
|
OpenApiTypes.STR,
|
||||||
OpenApiParameter("coaster_count_max", OpenApiTypes.INT, description="Maximum coaster count"),
|
description="Filter by country (comma-separated for multiple)",
|
||||||
OpenApiParameter("operator", OpenApiTypes.STR, description="Filter by operator slug (comma-separated for multiple)"),
|
),
|
||||||
OpenApiParameter("search", OpenApiTypes.STR, description="Search query for park names, descriptions, locations, and operators"),
|
OpenApiParameter(
|
||||||
OpenApiParameter("offset", OpenApiTypes.INT, description="Offset for progressive loading (server-side pagination)"),
|
"state",
|
||||||
|
OpenApiTypes.STR,
|
||||||
|
description="Filter by state (comma-separated for multiple)",
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
"opening_year_min", OpenApiTypes.INT, description="Minimum opening year"
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
"opening_year_max", OpenApiTypes.INT, description="Maximum opening year"
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
"size_min",
|
||||||
|
OpenApiTypes.NUMBER,
|
||||||
|
description="Minimum park size in acres",
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
"size_max",
|
||||||
|
OpenApiTypes.NUMBER,
|
||||||
|
description="Maximum park size in acres",
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
"rating_min", OpenApiTypes.NUMBER, description="Minimum average rating"
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
"rating_max", OpenApiTypes.NUMBER, description="Maximum average rating"
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
"ride_count_min", OpenApiTypes.INT, description="Minimum ride count"
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
"ride_count_max", OpenApiTypes.INT, description="Maximum ride count"
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
"coaster_count_min",
|
||||||
|
OpenApiTypes.INT,
|
||||||
|
description="Minimum coaster count",
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
"coaster_count_max",
|
||||||
|
OpenApiTypes.INT,
|
||||||
|
description="Maximum coaster count",
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
"operator",
|
||||||
|
OpenApiTypes.STR,
|
||||||
|
description="Filter by operator slug (comma-separated for multiple)",
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
"search",
|
||||||
|
OpenApiTypes.STR,
|
||||||
|
description="Search query for park names, descriptions, locations, and operators",
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
"offset",
|
||||||
|
OpenApiTypes.INT,
|
||||||
|
description="Offset for progressive loading (server-side pagination)",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
responses={
|
responses={
|
||||||
200: {
|
200: {
|
||||||
@@ -570,31 +688,33 @@ from apps.parks.services.hybrid_loader import smart_park_loader
|
|||||||
"properties": {
|
"properties": {
|
||||||
"parks": {
|
"parks": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {"$ref": "#/components/schemas/HybridParkSerializer"}
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/HybridParkSerializer"
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"total_count": {"type": "integer"},
|
"total_count": {"type": "integer"},
|
||||||
"strategy": {
|
"strategy": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["client_side", "server_side"],
|
"enum": ["client_side", "server_side"],
|
||||||
"description": "Filtering strategy used"
|
"description": "Filtering strategy used",
|
||||||
},
|
},
|
||||||
"has_more": {
|
"has_more": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Whether more data is available for progressive loading"
|
"description": "Whether more data is available for progressive loading",
|
||||||
},
|
},
|
||||||
"next_offset": {
|
"next_offset": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"nullable": True,
|
"nullable": True,
|
||||||
"description": "Next offset for progressive loading"
|
"description": "Next offset for progressive loading",
|
||||||
},
|
},
|
||||||
"filter_metadata": {
|
"filter_metadata": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Available filter options and ranges"
|
"description": "Available filter options and ranges",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
tags=["Parks"],
|
tags=["Parks"],
|
||||||
@@ -607,55 +727,62 @@ class HybridParkAPIView(APIView):
|
|||||||
Automatically chooses between client-side and server-side filtering
|
Automatically chooses between client-side and server-side filtering
|
||||||
based on data size and complexity. Provides progressive loading
|
based on data size and complexity. Provides progressive loading
|
||||||
for large datasets and complete data for smaller sets.
|
for large datasets and complete data for smaller sets.
|
||||||
|
|
||||||
|
Caching: 10-minute timeout (600s) - park lists are queried frequently
|
||||||
|
but need to reflect new additions within reasonable time.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
@cache_api_response(timeout=600, key_prefix="hybrid_parks")
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
"""Get parks with hybrid filtering strategy."""
|
"""Get parks with hybrid filtering strategy."""
|
||||||
try:
|
# Extract filters from query parameters
|
||||||
# Extract filters from query parameters
|
filters = self._extract_filters(request.query_params)
|
||||||
filters = self._extract_filters(request.query_params)
|
|
||||||
|
|
||||||
# Check if this is a progressive load request
|
# Check if this is a progressive load request
|
||||||
offset = request.query_params.get('offset')
|
offset = request.query_params.get("offset")
|
||||||
|
if offset is not None:
|
||||||
|
try:
|
||||||
|
offset = int(offset)
|
||||||
|
except ValueError:
|
||||||
|
return ErrorHandler.handle_api_error(
|
||||||
|
ValidationException("Invalid offset parameter"),
|
||||||
|
user_message="Invalid offset parameter",
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
if offset is not None:
|
if offset is not None:
|
||||||
try:
|
# Get progressive load data
|
||||||
offset = int(offset)
|
data = smart_park_loader.get_progressive_load(offset, filters)
|
||||||
# Get progressive load data
|
|
||||||
data = smart_park_loader.get_progressive_load(offset, filters)
|
|
||||||
except ValueError:
|
|
||||||
return Response(
|
|
||||||
{"error": "Invalid offset parameter"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# Get initial load data
|
# Get initial load data
|
||||||
data = smart_park_loader.get_initial_load(filters)
|
data = smart_park_loader.get_initial_load(filters)
|
||||||
|
|
||||||
# Serialize the parks data
|
# Serialize the parks data
|
||||||
serializer = HybridParkSerializer(data['parks'], many=True)
|
serializer = HybridParkSerializer(data["parks"], many=True)
|
||||||
|
|
||||||
# Prepare response
|
# Prepare response
|
||||||
response_data = {
|
response_data = {
|
||||||
'parks': serializer.data,
|
"parks": serializer.data,
|
||||||
'total_count': data['total_count'],
|
"total_count": data["total_count"],
|
||||||
'strategy': data.get('strategy', 'server_side'),
|
"strategy": data.get("strategy", "server_side"),
|
||||||
'has_more': data.get('has_more', False),
|
"has_more": data.get("has_more", False),
|
||||||
'next_offset': data.get('next_offset'),
|
"next_offset": data.get("next_offset"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Include filter metadata for initial loads
|
# Include filter metadata for initial loads
|
||||||
if 'filter_metadata' in data:
|
if "filter_metadata" in data:
|
||||||
response_data['filter_metadata'] = data['filter_metadata']
|
response_data["filter_metadata"] = data["filter_metadata"]
|
||||||
|
|
||||||
return Response(response_data, status=status.HTTP_200_OK)
|
return Response(response_data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
except Exception as e:
|
except ServiceError as e:
|
||||||
logger.error(f"Error in HybridParkAPIView: {e}")
|
return ErrorHandler.handle_api_error(
|
||||||
return Response(
|
e,
|
||||||
{"error": "Internal server error"},
|
user_message="Failed to load parks",
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _extract_filters(self, query_params):
|
def _extract_filters(self, query_params):
|
||||||
@@ -663,17 +790,20 @@ class HybridParkAPIView(APIView):
|
|||||||
filters = {}
|
filters = {}
|
||||||
|
|
||||||
# Handle comma-separated list parameters
|
# Handle comma-separated list parameters
|
||||||
list_params = ['status', 'park_type', 'country', 'state', 'operator']
|
list_params = ["status", "park_type", "country", "state", "operator"]
|
||||||
for param in list_params:
|
for param in list_params:
|
||||||
value = query_params.get(param)
|
value = query_params.get(param)
|
||||||
if value:
|
if value:
|
||||||
filters[param] = [v.strip() for v in value.split(',') if v.strip()]
|
filters[param] = [v.strip() for v in value.split(",") if v.strip()]
|
||||||
|
|
||||||
# Handle integer parameters
|
# Handle integer parameters
|
||||||
int_params = [
|
int_params = [
|
||||||
'opening_year_min', 'opening_year_max',
|
"opening_year_min",
|
||||||
'ride_count_min', 'ride_count_max',
|
"opening_year_max",
|
||||||
'coaster_count_min', 'coaster_count_max'
|
"ride_count_min",
|
||||||
|
"ride_count_max",
|
||||||
|
"coaster_count_min",
|
||||||
|
"coaster_count_max",
|
||||||
]
|
]
|
||||||
for param in int_params:
|
for param in int_params:
|
||||||
value = query_params.get(param)
|
value = query_params.get(param)
|
||||||
@@ -684,7 +814,7 @@ class HybridParkAPIView(APIView):
|
|||||||
pass # Skip invalid integer values
|
pass # Skip invalid integer values
|
||||||
|
|
||||||
# Handle float parameters
|
# Handle float parameters
|
||||||
float_params = ['size_min', 'size_max', 'rating_min', 'rating_max']
|
float_params = ["size_min", "size_max", "rating_min", "rating_max"]
|
||||||
for param in float_params:
|
for param in float_params:
|
||||||
value = query_params.get(param)
|
value = query_params.get(param)
|
||||||
if value:
|
if value:
|
||||||
@@ -694,9 +824,9 @@ class HybridParkAPIView(APIView):
|
|||||||
pass # Skip invalid float values
|
pass # Skip invalid float values
|
||||||
|
|
||||||
# Handle search parameter
|
# Handle search parameter
|
||||||
search = query_params.get('search')
|
search = query_params.get("search")
|
||||||
if search:
|
if search:
|
||||||
filters['search'] = search.strip()
|
filters["search"] = search.strip()
|
||||||
|
|
||||||
return filters
|
return filters
|
||||||
|
|
||||||
@@ -706,7 +836,11 @@ class HybridParkAPIView(APIView):
|
|||||||
summary="Get park filter metadata",
|
summary="Get park filter metadata",
|
||||||
description="Get available filter options and ranges for parks filtering.",
|
description="Get available filter options and ranges for parks filtering.",
|
||||||
parameters=[
|
parameters=[
|
||||||
OpenApiParameter("scoped", OpenApiTypes.BOOL, description="Whether to scope metadata to current filters"),
|
OpenApiParameter(
|
||||||
|
"scoped",
|
||||||
|
OpenApiTypes.BOOL,
|
||||||
|
description="Whether to scope metadata to current filters",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
responses={
|
responses={
|
||||||
200: {
|
200: {
|
||||||
@@ -719,21 +853,33 @@ class HybridParkAPIView(APIView):
|
|||||||
"categorical": {
|
"categorical": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"countries": {"type": "array", "items": {"type": "string"}},
|
"countries": {
|
||||||
"states": {"type": "array", "items": {"type": "string"}},
|
"type": "array",
|
||||||
"park_types": {"type": "array", "items": {"type": "string"}},
|
"items": {"type": "string"},
|
||||||
"statuses": {"type": "array", "items": {"type": "string"}},
|
},
|
||||||
|
"states": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
},
|
||||||
|
"park_types": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
},
|
||||||
|
"statuses": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
},
|
||||||
"operators": {
|
"operators": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {"type": "string"},
|
"name": {"type": "string"},
|
||||||
"slug": {"type": "string"}
|
"slug": {"type": "string"},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
"ranges": {
|
"ranges": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -741,45 +887,75 @@ class HybridParkAPIView(APIView):
|
|||||||
"opening_year": {
|
"opening_year": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"min": {"type": "integer", "nullable": True},
|
"min": {
|
||||||
"max": {"type": "integer", "nullable": True}
|
"type": "integer",
|
||||||
}
|
"nullable": True,
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "integer",
|
||||||
|
"nullable": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"size_acres": {
|
"size_acres": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"min": {"type": "number", "nullable": True},
|
"min": {
|
||||||
"max": {"type": "number", "nullable": True}
|
"type": "number",
|
||||||
}
|
"nullable": True,
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "number",
|
||||||
|
"nullable": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"average_rating": {
|
"average_rating": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"min": {"type": "number", "nullable": True},
|
"min": {
|
||||||
"max": {"type": "number", "nullable": True}
|
"type": "number",
|
||||||
}
|
"nullable": True,
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "number",
|
||||||
|
"nullable": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"ride_count": {
|
"ride_count": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"min": {"type": "integer", "nullable": True},
|
"min": {
|
||||||
"max": {"type": "integer", "nullable": True}
|
"type": "integer",
|
||||||
}
|
"nullable": True,
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "integer",
|
||||||
|
"nullable": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"coaster_count": {
|
"coaster_count": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"min": {"type": "integer", "nullable": True},
|
"min": {
|
||||||
"max": {"type": "integer", "nullable": True}
|
"type": "integer",
|
||||||
}
|
"nullable": True,
|
||||||
}
|
},
|
||||||
}
|
"max": {
|
||||||
|
"type": "integer",
|
||||||
|
"nullable": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"total_count": {"type": "integer"}
|
"total_count": {"type": "integer"},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
tags=["Parks"],
|
tags=["Parks"],
|
||||||
@@ -791,30 +967,33 @@ class ParkFilterMetadataAPIView(APIView):
|
|||||||
|
|
||||||
Provides information about available filter options and ranges
|
Provides information about available filter options and ranges
|
||||||
to help build dynamic filter interfaces.
|
to help build dynamic filter interfaces.
|
||||||
|
|
||||||
|
Caching: 30-minute timeout (1800s) - filter metadata is stable
|
||||||
|
and only changes when new entities are added.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
@cache_api_response(timeout=1800, key_prefix="park_filter_metadata")
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
"""Get park filter metadata."""
|
"""Get park filter metadata."""
|
||||||
|
# Check if metadata should be scoped to current filters
|
||||||
|
scoped = request.query_params.get("scoped", "").lower() == "true"
|
||||||
|
filters = None
|
||||||
|
|
||||||
|
if scoped:
|
||||||
|
filters = self._extract_filters(request.query_params)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if metadata should be scoped to current filters
|
|
||||||
scoped = request.query_params.get('scoped', '').lower() == 'true'
|
|
||||||
filters = None
|
|
||||||
|
|
||||||
if scoped:
|
|
||||||
filters = self._extract_filters(request.query_params)
|
|
||||||
|
|
||||||
# Get filter metadata
|
# Get filter metadata
|
||||||
metadata = smart_park_loader.get_filter_metadata(filters)
|
metadata = smart_park_loader.get_filter_metadata(filters)
|
||||||
|
|
||||||
return Response(metadata, status=status.HTTP_200_OK)
|
return Response(metadata, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
except Exception as e:
|
except ServiceError as e:
|
||||||
logger.error(f"Error in ParkFilterMetadataAPIView: {e}")
|
return ErrorHandler.handle_api_error(
|
||||||
return Response(
|
e,
|
||||||
{"error": "Internal server error"},
|
user_message="Failed to get filter metadata",
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _extract_filters(self, query_params):
|
def _extract_filters(self, query_params):
|
||||||
|
|||||||
12
backend/apps/api/v1/rides/company_urls.py
Normal file
12
backend/apps/api/v1/rides/company_urls.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""URL routes for Company CRUD API."""
|
||||||
|
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .company_views import CompanyDetailAPIView, CompanyListCreateAPIView
|
||||||
|
|
||||||
|
app_name = "api_v1_companies"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", CompanyListCreateAPIView.as_view(), name="company-list-create"),
|
||||||
|
path("<int:pk>/", CompanyDetailAPIView.as_view(), name="company-detail"),
|
||||||
|
]
|
||||||
167
backend/apps/api/v1/rides/company_views.py
Normal file
167
backend/apps/api/v1/rides/company_views.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""
|
||||||
|
Company API views for ThrillWiki API v1.
|
||||||
|
|
||||||
|
This module implements CRUD endpoints for company management:
|
||||||
|
- List / Create: GET /companies/ POST /companies/
|
||||||
|
- Retrieve / Update / Delete: GET /companies/{id}/ PATCH/PUT/DELETE
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db.models import Q
|
||||||
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
|
from rest_framework import permissions, status
|
||||||
|
from rest_framework.exceptions import NotFound
|
||||||
|
from rest_framework.pagination import PageNumberPagination
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from apps.api.v1.serializers.companies import (
|
||||||
|
CompanyCreateInputSerializer,
|
||||||
|
CompanyDetailOutputSerializer,
|
||||||
|
CompanyUpdateInputSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from apps.rides.models.company import Company
|
||||||
|
MODELS_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
Company = None
|
||||||
|
MODELS_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
|
class StandardResultsSetPagination(PageNumberPagination):
|
||||||
|
page_size = 20
|
||||||
|
page_size_query_param = "page_size"
|
||||||
|
max_page_size = 100
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyListCreateAPIView(APIView):
|
||||||
|
"""List and create companies."""
|
||||||
|
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary="List all companies",
|
||||||
|
description="List companies with optional search and role filtering.",
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR),
|
||||||
|
OpenApiParameter(name="role", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR),
|
||||||
|
OpenApiParameter(name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT),
|
||||||
|
OpenApiParameter(name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT),
|
||||||
|
],
|
||||||
|
responses={200: CompanyDetailOutputSerializer(many=True)},
|
||||||
|
tags=["Companies"],
|
||||||
|
)
|
||||||
|
def get(self, request: Request) -> Response:
|
||||||
|
if not MODELS_AVAILABLE:
|
||||||
|
return Response(
|
||||||
|
{"detail": "Company models not available"},
|
||||||
|
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
qs = Company.objects.all().order_by("name")
|
||||||
|
|
||||||
|
# Search filter
|
||||||
|
search = request.query_params.get("search", "")
|
||||||
|
if search:
|
||||||
|
qs = qs.filter(
|
||||||
|
Q(name__icontains=search) | Q(description__icontains=search)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Role filter
|
||||||
|
role = request.query_params.get("role", "")
|
||||||
|
if role:
|
||||||
|
qs = qs.filter(roles__contains=[role])
|
||||||
|
|
||||||
|
paginator = StandardResultsSetPagination()
|
||||||
|
page = paginator.paginate_queryset(qs, request)
|
||||||
|
serializer = CompanyDetailOutputSerializer(page, many=True)
|
||||||
|
return paginator.get_paginated_response(serializer.data)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary="Create a new company",
|
||||||
|
description="Create a new company with the given details.",
|
||||||
|
request=CompanyCreateInputSerializer,
|
||||||
|
responses={201: CompanyDetailOutputSerializer()},
|
||||||
|
tags=["Companies"],
|
||||||
|
)
|
||||||
|
def post(self, request: Request) -> Response:
|
||||||
|
if not MODELS_AVAILABLE:
|
||||||
|
return Response(
|
||||||
|
{"detail": "Company models not available"},
|
||||||
|
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer_in = CompanyCreateInputSerializer(data=request.data)
|
||||||
|
serializer_in.is_valid(raise_exception=True)
|
||||||
|
validated = serializer_in.validated_data
|
||||||
|
|
||||||
|
company = Company.objects.create(
|
||||||
|
name=validated["name"],
|
||||||
|
roles=validated["roles"],
|
||||||
|
description=validated.get("description", ""),
|
||||||
|
website=validated.get("website", ""),
|
||||||
|
founded_date=validated.get("founded_date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = CompanyDetailOutputSerializer(company)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyDetailAPIView(APIView):
|
||||||
|
"""Retrieve, update, and delete a company."""
|
||||||
|
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def _get_company_or_404(self, pk: int) -> "Company":
|
||||||
|
if not MODELS_AVAILABLE:
|
||||||
|
raise NotFound("Company models not available")
|
||||||
|
try:
|
||||||
|
return Company.objects.get(pk=pk)
|
||||||
|
except Company.DoesNotExist:
|
||||||
|
raise NotFound("Company not found")
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary="Retrieve a company",
|
||||||
|
description="Get detailed information about a specific company.",
|
||||||
|
responses={200: CompanyDetailOutputSerializer()},
|
||||||
|
tags=["Companies"],
|
||||||
|
)
|
||||||
|
def get(self, request: Request, pk: int) -> Response:
|
||||||
|
company = self._get_company_or_404(pk)
|
||||||
|
serializer = CompanyDetailOutputSerializer(company)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary="Update a company",
|
||||||
|
description="Update a company (partial update supported).",
|
||||||
|
request=CompanyUpdateInputSerializer,
|
||||||
|
responses={200: CompanyDetailOutputSerializer()},
|
||||||
|
tags=["Companies"],
|
||||||
|
)
|
||||||
|
def patch(self, request: Request, pk: int) -> Response:
|
||||||
|
company = self._get_company_or_404(pk)
|
||||||
|
serializer_in = CompanyUpdateInputSerializer(data=request.data, partial=True)
|
||||||
|
serializer_in.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
for field, value in serializer_in.validated_data.items():
|
||||||
|
setattr(company, field, value)
|
||||||
|
company.save()
|
||||||
|
|
||||||
|
serializer = CompanyDetailOutputSerializer(company)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def put(self, request: Request, pk: int) -> Response:
|
||||||
|
return self.patch(request, pk)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary="Delete a company",
|
||||||
|
description="Delete a company.",
|
||||||
|
responses={204: None},
|
||||||
|
tags=["Companies"],
|
||||||
|
)
|
||||||
|
def delete(self, request: Request, pk: int) -> Response:
|
||||||
|
company = self._get_company_or_404(pk)
|
||||||
|
company.delete()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -11,17 +11,17 @@ This file exposes comprehensive endpoints for ride model management:
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import (
|
from .views import (
|
||||||
RideModelListCreateAPIView,
|
|
||||||
RideModelDetailAPIView,
|
RideModelDetailAPIView,
|
||||||
RideModelSearchAPIView,
|
|
||||||
RideModelFilterOptionsAPIView,
|
RideModelFilterOptionsAPIView,
|
||||||
RideModelStatsAPIView,
|
RideModelListCreateAPIView,
|
||||||
RideModelVariantListCreateAPIView,
|
|
||||||
RideModelVariantDetailAPIView,
|
|
||||||
RideModelTechnicalSpecListCreateAPIView,
|
|
||||||
RideModelTechnicalSpecDetailAPIView,
|
|
||||||
RideModelPhotoListCreateAPIView,
|
|
||||||
RideModelPhotoDetailAPIView,
|
RideModelPhotoDetailAPIView,
|
||||||
|
RideModelPhotoListCreateAPIView,
|
||||||
|
RideModelSearchAPIView,
|
||||||
|
RideModelStatsAPIView,
|
||||||
|
RideModelTechnicalSpecDetailAPIView,
|
||||||
|
RideModelTechnicalSpecListCreateAPIView,
|
||||||
|
RideModelVariantDetailAPIView,
|
||||||
|
RideModelVariantListCreateAPIView,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = "api_v1_ride_models"
|
app_name = "api_v1_ride_models"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user