Compare commits

..

2 Commits

Author SHA1 Message Date
pacnpal
8dd5d88906 Last with the old frontend 2025-08-28 11:38:22 -04:00
pacnpal
c4702559fb Last with the old frontend 2025-08-28 11:37:24 -04:00
979 changed files with 170642 additions and 69687 deletions

View File

@@ -1,12 +0,0 @@
{
"permissions": {
"allow": [
"Bash(python manage.py check:*)",
"Bash(uv run:*)",
"Bash(find:*)",
"Bash(python:*)"
],
"deny": [],
"ask": []
}
}

View File

@@ -1,98 +0,0 @@
---
description: Core ThrillWiki development rules covering API organization, data models, development commands, code quality standards, and critical business rules
author: ThrillWiki Development Team
version: 1.0
globs: ["**/*.py", "apps/**/*", "thrillwiki/**/*", "**/*.md"]
tags: ["django", "api-design", "code-quality", "development-commands", "business-rules"]
---
# ThrillWiki Core Development Rules
## Objective
This rule defines the fundamental development standards, API organization patterns, code quality requirements, and critical business rules that MUST be followed for all ThrillWiki development work. It ensures consistency, maintainability, and adherence to project-specific constraints.
## API Organization and Data Models
### Mandatory API Structure
- **MANDATORY NESTING**: All API directory structures MUST match URL nesting patterns. No exceptions.
- **NO TOP-LEVEL ENDPOINTS**: URLs must be nested under top-level domains
- **MANDATORY TRAILING SLASHES**: All API endpoints MUST include trailing forward slashes unless ending with query parameters
- **Validation Required**: Validate all endpoint URLs against the mandatory trailing slash rule
### Ride System Architecture
**RIDE TYPES vs RIDE MODELS**: These are separate concepts for ALL ride categories:
- **Ride Types**: How rides operate (e.g., "inverted", "trackless", "spinning", "log flume", "monorail")
- **Ride Models**: Specific manufacturer products (e.g., "B&M Dive Coaster", "Vekoma Boomerang")
- **Implementation**: Individual rides reference BOTH the model (what product) and type (how it operates)
- **Coverage**: Ride types MUST be available for ALL ride categories, not just roller coasters
## Development Commands and Code Quality
### Required Commands
- **Django Server**: ALWAYS use `uv run manage.py runserver_plus` instead of `python manage.py runserver`
- **Django Migrations**: ALWAYS use `uv run manage.py makemigrations` and `uv run manage.py migrate` instead of `python manage.py`
- **Package Management**: ALWAYS use `uv add <package>` instead of `pip install <package>`
- **Django Management**: ALWAYS use `uv run manage.py <command>` instead of `python manage.py <command>`
### Code Quality Standards
- **Cognitive Complexity**: Break down methods with high cognitive complexity (>15) into smaller, focused helper methods
- **Method Extraction**: Extract logical operations into separate methods with descriptive names
- **Single Responsibility**: Each method SHOULD have one clear purpose
- **Logic Structure**: Prefer composition over deeply nested conditional logic
- **Null Handling**: ALWAYS handle None values explicitly to avoid type errors
- **Type Annotations**: Use proper type annotations, including union types (e.g., `Polygon | None`)
- **API Structure**: Structure API views with clear separation between parameter handling, business logic, and response building
- **Quality Improvements**: When addressing SonarQube or linting warnings, focus on structural improvements rather than quick fixes
## ThrillWiki Project Rules
### Domain Architecture
- **Domain Structure**: Parks contain rides, rides have models, companies have multiple roles (manufacturer/operator/designer)
- **Media Integration**: Use CloudflareImagesField for all photo uploads with variants and transformations
- **Change Tracking**: All models use pghistory for change tracking and TrackedModel base class
- **Slug Management**: Unique within scope (park slugs global, ride slugs within park, ride model slugs within manufacturer)
### Status and Role Management
- **Status Management**: Rides have operational status (OPERATING, CLOSED_TEMP, SBNO, etc.) with date tracking
- **Company Roles**: Companies can be MANUFACTURER, OPERATOR, DESIGNER, PROPERTY_OWNER with array field
- **Location Data**: Use PostGIS for geographic data, separate location models for parks and rides
### Technical Patterns
- **API Patterns**: Use DRF with drf-spectacular, comprehensive serializers, nested endpoints, caching
- **Photo Management**: Banner/card image references, photo types, attribution fields, primary photo logic
- **Search Integration**: Text search, filtering, autocomplete endpoints, pagination
- **Statistics**: Cached stats endpoints with automatic invalidation via Django signals
## CRITICAL RULES
### Data Integrity (ABSOLUTE)
🚨 **NEVER MOCK DATA**: You are NEVER EVER to mock any data unless it's ONLY for API schema documentation purposes. All data MUST come from real database queries and actual model instances. Mock data is STRICTLY FORBIDDEN in all API responses, services, and business logic.
### Domain Separation (CRITICAL BUSINESS RULE)
🚨 **DOMAIN SEPARATION**: Company roles OPERATOR and PROPERTY_OWNER are EXCLUSIVELY for parks domain. They SHOULD NEVER be used in rides URLs or ride-related contexts. Only MANUFACTURER and DESIGNER roles are for rides domain.
**Correct URL Patterns:**
- **Parks**: `/parks/{park_slug}/` and `/parks/`
- **Rides**: `/parks/{park_slug}/rides/{ride_slug}/` and `/rides/`
- **Parks Companies**: `/parks/operators/{operator_slug}/` and `/parks/owners/{owner_slug}/`
- **Rides Companies**: `/rides/manufacturers/{manufacturer_slug}/` and `/rides/designers/{designer_slug}/`
⚠️ **WARNING**: NEVER mix these domains - this is a fundamental and DANGEROUS business rule violation.
### Photo Management Standards
🚨 **PHOTO MANAGEMENT**:
- Use CloudflareImagesField for all photo uploads with variants and transformations
- Clearly define and use photo types (e.g., banner, card) for all images
- Include attribution fields for all photos
- Implement logic to determine the primary photo for each model
## Verification Checklist
Before implementing any changes, verify:
- [ ] All API endpoints have trailing slashes
- [ ] Domain separation is maintained (parks vs rides companies)
- [ ] No mock data is used outside of schema documentation
- [ ] Proper uv commands are used for all Django operations
- [ ] Type annotations are complete and accurate
- [ ] Methods follow single responsibility principle
- [ ] CloudflareImagesField is used for all photo uploads

View File

@@ -1,100 +0,0 @@
---
description: Mandatory Rich Choice Objects system enforcement for ThrillWiki project replacing Django tuple-based choices with rich metadata-driven choice fields
author: ThrillWiki Development Team
version: 1.0
globs: ["apps/**/choices.py", "apps/**/models.py", "apps/**/serializers.py", "apps/**/__init__.py"]
tags: ["django", "choices", "rich-choice-objects", "data-modeling", "mandatory"]
---
# Rich Choice Objects System (MANDATORY)
## Objective
This rule enforces the mandatory use of the Rich Choice Objects system instead of Django's traditional tuple-based choices for ALL choice fields in the ThrillWiki project. It ensures consistent, metadata-rich choice handling with enhanced UI capabilities and maintainable code patterns.
## Brief Overview
Mandatory use of Rich Choice Objects system instead of Django tuple-based choices for all choice fields in ThrillWiki project.
## Rich Choice Objects Enforcement
### Absolute Requirements
🚨 **NEVER use Django tuple-based choices** (e.g., `choices=[('VALUE', 'Label')]`) - ALWAYS use RichChoiceField
### Implementation Standards
- **Field Usage**: All choice fields MUST use `RichChoiceField(choice_group="group_name", domain="domain_name")` pattern
- **Choice Definitions**: MUST be created in domain-specific `choices.py` files using RichChoice dataclass
- **Rich Metadata**: All choices MUST include rich metadata (color, icon, description, css_class at minimum)
- **Registration**: Choice groups MUST be registered with global registry using `register_choices()` function
- **Auto-Registration**: Import choices in domain `__init__.py` to trigger auto-registration on Django startup
### Required Patterns
- **Categorization**: Use ChoiceCategory enum for proper categorization (STATUS, CLASSIFICATION, TECHNICAL, SECURITY)
- **Business Logic**: Leverage rich metadata for UI styling, permissions, and business logic instead of hardcoded values
- **Serialization**: Update serializers to use RichChoiceSerializer for choice fields
### Migration Requirements
- **NO Backwards Compatibility**: DO NOT maintain backwards compatibility with tuple-based choices - migrate fully to Rich Choice Objects
- **Model Refactoring**: Ensure all existing models using tuple-based choices are refactored to use RichChoiceField
- **Validation**: Validate choice groups are correctly loaded in registry during application startup
### Domain Consistency
- **Follow Established Patterns**: Follow established patterns from rides, parks, and accounts domains for consistency
- **Domain-Specific Organization**: Maintain domain-specific choice organization in separate `choices.py` files
## Implementation Checklist
Before implementing choice fields, verify:
- [ ] RichChoiceField is used instead of Django tuple choices
- [ ] Choice group and domain are properly specified
- [ ] Rich metadata includes color, icon, description, css_class
- [ ] Choices are defined in domain-specific `choices.py` file
- [ ] Choice group is registered with `register_choices()` function
- [ ] Domain `__init__.py` imports choices for auto-registration
- [ ] Appropriate ChoiceCategory enum is used
- [ ] Serializers use RichChoiceSerializer for choice fields
- [ ] No tuple-based choices remain in the codebase
## Examples
### ✅ CORRECT Implementation
```python
# In apps/rides/choices.py
from core.choices import RichChoice, ChoiceCategory, register_choices
RIDE_STATUS_CHOICES = [
RichChoice(
value="operating",
label="Operating",
color="#10b981",
icon="check-circle",
description="Ride is currently operating normally",
css_class="status-operating",
category=ChoiceCategory.STATUS
),
# ... more choices
]
register_choices("ride_status", RIDE_STATUS_CHOICES, domain="rides")
# In models.py
status = RichChoiceField(choice_group="ride_status", domain="rides")
```
### ❌ FORBIDDEN Implementation
```python
# NEVER DO THIS - Tuple-based choices are forbidden
STATUS_CHOICES = [
('operating', 'Operating'),
('closed', 'Closed'),
]
status = models.CharField(max_length=20, choices=STATUS_CHOICES)
```
## Verification Steps
To ensure compliance:
1. Search codebase for any remaining tuple-based choice patterns
2. Verify all choice fields use RichChoiceField
3. Confirm all choices have complete rich metadata
4. Test choice group registration during application startup
5. Validate serializers use RichChoiceSerializer where appropriate

View File

@@ -1,161 +0,0 @@
---
description: Comprehensive ThrillWiki Django project context including architecture, development patterns, business rules, and mandatory Context7 MCP integration workflow
author: ThrillWiki Development Team
version: 2.0
globs: ["**/*.py", "**/*.html", "**/*.js", "**/*.css", "**/*.md"]
tags: ["django", "architecture", "api-design", "business-rules", "context7-integration", "thrillwiki"]
---
# ThrillWiki Django Project Context
## Objective
This rule provides comprehensive context for the ThrillWiki project, defining core architecture patterns, business rules, development workflows, and mandatory integration requirements. It serves as the primary reference for maintaining consistency across all ThrillWiki development activities.
## Project Overview
ThrillWiki is a comprehensive theme park database platform with user-generated content, expert moderation, and rich media support. Built with Django REST Framework, it serves 120+ API endpoints for parks, rides, companies, and user management.
## Core Architecture
### Technology Stack
- **Backend**: Django 5.0+ with DRF, PostgreSQL + PostGIS, Redis caching, Celery tasks
- **Frontend**: HTMX + AlpineJS + Tailwind CSS + Django-Cotton
- 🚨 **CRITICAL**: NO React/Vue/Angular allowed
- **Media**: Cloudflare Images using Direct Upload with variants and transformations
- **Tracking**: pghistory for all model changes, TrackedModel base class
- **Choices**: Rich Choice Objects system (NEVER use Django tuple choices)
### Domain Architecture
- **Parks Domain**: `parks/`, companies (OPERATOR/PROPERTY_OWNER roles only)
- **Rides Domain**: `rides/`, companies (MANUFACTURER/DESIGNER roles only)
- **Core Apps**: `accounts/`, `media/`, `moderation/`, `core/`
- 🚨 **CRITICAL BUSINESS RULE**: Never mix park/ride company roles - fundamental business rule violation
## Development Patterns
### Model Patterns
- **Base Classes**: All models MUST inherit from TrackedModel
- **Slug Handling**: Use SluggedModel for slugs with history tracking
- **Location Data**: Use PostGIS for geographic data, separate location models
- **Media Fields**: Use CloudflareImagesField for all image handling
### API Design Patterns
- **URL Structure**: Nested URLs (`/parks/{slug}/rides/{slug}/`)
- **Trailing Slashes**: MANDATORY trailing slashes on all endpoints
- **Authentication**: Token-based with role hierarchy (USER/MODERATOR/ADMIN/SUPERUSER)
- **Filtering**: Comprehensive filtering - rides (25+ parameters), parks (15+ parameters)
- **Responses**: Standard DRF pagination, rich error responses with details
- **Caching**: Multi-level (Redis, CDN, browser) with signal-based invalidation
### Choice System (MANDATORY)
- **Implementation**: `RichChoiceField(choice_group="group_name", domain="domain_name")`
- **Definition**: Domain-specific `choices.py` using RichChoice dataclass
- **Registration**: `register_choices()` function in domain `__init__.py`
- **Required Metadata**: color, icon, description, css_class (minimum)
- 🚨 **FORBIDDEN**: NO tuple-based choices allowed anywhere in codebase
## Development Commands
### Package Management
- **Python Packages**: `uv add <package>` (NOT `pip install`)
- **Server**: `uv run manage.py runserver_plus` (NOT `python manage.py`)
- **Migrations**: `uv run manage.py makemigrations/migrate`
- **Management**: ALWAYS use `uv run manage.py <command>`
## Business Rules
### Company Role Separation
- **Parks Domain**: Only OPERATOR and PROPERTY_OWNER roles
- **Rides Domain**: Only MANUFACTURER and DESIGNER roles
- 🚨 **CRITICAL**: Never allow cross-domain company roles
### Data Integrity
- **Model Changes**: All must be tracked via pghistory
- **API Responses**: MUST use real database data (NEVER MOCK DATA)
- **Geographic Data**: MUST use PostGIS for accuracy
## Frontend Constraints
### Architecture Requirements
- **HTMX**: Dynamic updates and AJAX interactions
- **AlpineJS**: Client-side state management
- **Tailwind CSS**: Styling framework
- **Progressive Enhancement**: Required approach
### Performance Targets
- **First Contentful Paint**: < 1.5s
- **Time to Interactive**: < 2s
- **Compliance**: Core Web Vitals compliance
- **Browser Support**: Latest 2 versions of major browsers
## Context7 MCP Integration (MANDATORY)
### Requirement
🚨 **CRITICAL**: ALWAYS use Context7 MCP for documentation lookups before making changes
### Libraries Requiring Context7
- **tailwindcss**: CSS utility classes, responsive design, component styling
- **django**: Models, views, forms, URL patterns, Django-specific patterns
- **django-cotton**: Component creation, template organization, Cotton-specific syntax
- **htmx**: Dynamic updates, form handling, AJAX interactions
- **alpinejs**: Client-side state management, reactive data, JavaScript interactions
- **django-rest-framework**: API design, serializers, viewsets, DRF patterns
- **postgresql**: Database queries, PostGIS functions, advanced SQL features
- **postgis**: Geographic data handling and spatial queries
- **redis**: Caching strategies, session management, performance optimization
### Mandatory Workflow Steps
1. **Before editing/creating code**: Query Context7 for relevant library documentation
2. **During debugging**: Use Context7 to verify syntax, patterns, and best practices
3. **When implementing new features**: Reference Context7 for current API and method signatures
4. **For performance issues**: Consult Context7 for optimization techniques and patterns
5. **For geographic data handling**: Use Context7 for PostGIS functions and best practices
6. **For caching strategies**: Refer to Context7 for Redis patterns and best practices
7. **For database queries**: Utilize Context7 for PostgreSQL best practices and advanced SQL features
### Mandatory Scenarios
- Creating new Django models or API endpoints
- Implementing HTMX dynamic functionality
- Writing AlpineJS reactive components
- Designing responsive layouts with Tailwind CSS
- Creating Django-Cotton components
- Debugging CSS, JavaScript, or Django issues
- Implementing caching or database optimizations
- Handling geographic data with PostGIS
- Utilizing Redis for session management
- Implementing real-time features with WebSockets
### Context7 Commands
1. **Resolve Library**: Always call `Context7:resolve-library-id` first to get correct library ID
2. **Get Documentation**: Then use `Context7:get-library-docs` with appropriate topic parameter
### Example Topics by Library
- **tailwindcss**: responsive design, flexbox, grid, animations
- **django**: models, views, forms, admin, signals
- **django-cotton**: components, templates, slots, props
- **htmx**: hx-get, hx-post, hx-swap, hx-trigger, hx-target
- **alpinejs**: x-data, x-show, x-if, x-for, x-model
- **django-rest-framework**: serializers, viewsets, routers, permissions
- **postgresql**: joins, indexes, transactions, window functions
- **postgis**: geospatial queries, distance calculations, spatial indexes
- **redis**: caching strategies, pub/sub, data structures
## Code Quality Standards
### Model Requirements
- All models MUST inherit from TrackedModel
- Use SluggedModel for entities with slugs and history tracking
- Always use RichChoiceField instead of Django choices
- Use CloudflareImagesField for all image handling
- Use PostGIS fields and separate location models for geographic data
### API Requirements
- MUST include trailing slashes and follow nested pattern
- All responses MUST use real database queries
- Implement comprehensive filtering and pagination
- Use signal-based cache invalidation
### Development Workflow
- Use uv for all Python package operations
- Use runserver_plus for enhanced development server
- Always use `uv run` for Django management commands
- All functionality MUST work with progressive enhancement

View File

@@ -1,56 +0,0 @@
---
description: Condensed ThrillWiki Django project context with architecture, patterns, and mandatory Context7 integration
author: ThrillWiki Development Team
version: 2.1
globs: ["**/*.py", "**/*.html", "**/*.js", "**/*.css", "**/*.md"]
tags: ["django", "architecture", "context7-integration", "thrillwiki"]
---
# ThrillWiki Django Project Context
## Project Overview
Theme park database platform with Django REST Framework serving 120+ API endpoints for parks, rides, companies, and users.
## Core Architecture
- **Backend**: Django 5.1+, DRF, PostgreSQL+PostGIS, Redis, Celery
- **Frontend**: HTMX (V2+) + AlpineJS + Tailwind CSS (V4+) + Django-Cotton
- 🚨 **ABSOLUTELY NO Custom JS** - use HTMX + AlpineJS ONLY
- Clean, simple UX preferred
- **Media**: Cloudflare Images with Direct Upload
- **Tracking**: pghistory, TrackedModel base class
- **Choices**: Rich Choice Objects (NEVER Django tuple choices)
## Development Patterns
- **Models**: TrackedModel inheritance, SluggedModel for slugs, PostGIS for location
- **APIs**: Nested URLs (`/parks/{slug}/rides/{slug}/`), mandatory trailing slashes
- **Commands**: `uv add <package>`, `uv run manage.py <command>` (NOT pip/python)
- **Choices**: `RichChoiceField(choice_group="name", domain="domain")` MANDATORY
## Business Rules
🚨 **CRITICAL**: Company role separation - Parks (OPERATOR/PROPERTY_OWNER only), Rides (MANUFACTURER/DESIGNER only)
## Context7 MCP Integration (MANDATORY)
### Required Libraries
tailwindcss, django, django-cotton, htmx, alpinejs, django-rest-framework, postgresql, postgis, redis
### Workflow
1. **ALWAYS** call `Context7:resolve-library-id` first
2. Then `Context7:get-library-docs` with topic parameter
3. Required for: new models/APIs, HTMX functionality, AlpineJS components, Tailwind layouts, Cotton components, debugging, optimizations
### Example Topics
- **tailwindcss**: responsive, flexbox, grid
- **django**: models, views, forms
- **htmx**: hx-get, hx-post, hx-swap, hx-target
- **alpinejs**: x-data, x-show, x-if, x-for
## Standards
- All models inherit TrackedModel
- Real database data only (NO MOCKING)
- RichChoiceField over Django choices
- Progressive enhancement required
- We prefer to edit existing files instead of creating new ones.
YOU ARE STRICTLY AND ABSOLUTELY FORBIDDEN FROM IGNORING, BYPASSING, OR AVOIDING THESE RULES IN ANY WAY WITH NO EXCEPTIONS!!!

View File

@@ -1,54 +0,0 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
Please review this pull request and provide feedback on:
- Code quality and best practices
- Potential bugs or issues
- Performance considerations
- Security concerns
- Test coverage
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'

View File

@@ -1,50 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options
# claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)'

View File

@@ -15,7 +15,7 @@ jobs:
python-version: [3.13.1]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Install Homebrew on Linux
if: runner.os == 'Linux'

View File

@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
environment: development_environment
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0

9
.gitignore vendored
View File

@@ -116,11 +116,4 @@ temp/
/backups/
.django_tailwind_cli/
backend/.env
frontend/.env
# Extracted packages
django-forwardemail/
frontend/
frontend
.snapshots
uv.lock
frontend/.env

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
lts/*

73
.replit
View File

@@ -1,73 +0,0 @@
modules = ["bash", "web", "nodejs-20", "python-3.13", "postgresql-16"]
[nix]
channel = "stable-25_05"
packages = [
"freetype",
"gdal",
"geos",
"gitFull",
"lcms2",
"libimagequant",
"libjpeg",
"libtiff",
"libwebp",
"libxcrypt",
"openjpeg",
"playwright-driver",
"postgresql",
"proj",
"tcl",
"tk",
"uv",
"zlib",
]
[agent]
expertMode = true
[workflows]
runButton = "Project"
[[workflows.workflow]]
name = "Project"
mode = "parallel"
author = "agent"
[[workflows.workflow.tasks]]
task = "workflow.run"
args = "ThrillWiki Server"
[[workflows.workflow]]
name = "ThrillWiki Server"
author = "agent"
[[workflows.workflow.tasks]]
task = "shell.exec"
args = "/home/runner/workspace/.venv/bin/python manage.py tailwind runserver 0.0.0.0:5000"
waitForPort = 5000
[workflows.workflow.metadata]
outputType = "webview"
[[ports]]
localPort = 5000
externalPort = 80
[[ports]]
localPort = 41923
externalPort = 3000
[[ports]]
localPort = 45245
externalPort = 3001
[deployment]
deploymentTarget = "autoscale"
run = [
"gunicorn",
"--bind=0.0.0.0:5000",
"--reuse-port",
"thrillwiki.wsgi:application",
]
build = ["uv", "pip", "install", "--system", "-r", "requirements.txt"]

View File

@@ -1,18 +0,0 @@
{
"mcpServers": {
"context7": {
"command": "npx",
"args": [
"-y",
"@upstash/context7-mcp"
],
"env": {
"DEFAULT_MINIMUM_TOKENS": ""
},
"alwaysAllow": [
"resolve-library-id",
"get-library-docs"
]
}
}
}

277
CI_README.md Normal file
View File

@@ -0,0 +1,277 @@
# ThrillWiki CI/CD System
This repository includes a **complete automated CI/CD system** that creates a Linux VM on Unraid and automatically deploys ThrillWiki when commits are pushed to GitHub.
## 🚀 Complete Automation (Unraid)
For **full automation** including VM creation on Unraid:
```bash
./scripts/unraid/setup-complete-automation.sh
```
This single command will:
- ✅ Create and configure VM on Unraid
- ✅ Install Ubuntu Server with all dependencies
- ✅ Deploy ThrillWiki application
- ✅ Set up automated CI/CD pipeline
- ✅ Configure webhook listener
- ✅ Test the entire system
## Manual Setup (Any Linux VM)
For manual setup on existing Linux VMs:
```bash
./scripts/setup-vm-ci.sh
```
## System Components
### 📁 Files Created
```
scripts/
├── ci-start.sh # Local development server startup
├── webhook-listener.py # GitHub webhook listener
├── vm-deploy.sh # VM deployment script
├── setup-vm-ci.sh # Manual VM setup script
├── unraid/
│ ├── vm-manager.py # Unraid VM management
│ └── setup-complete-automation.sh # Complete automation
└── systemd/
├── thrillwiki.service # Django app service
└── thrillwiki-webhook.service # Webhook listener service
docs/
├── VM_DEPLOYMENT_SETUP.md # Manual setup documentation
└── UNRAID_COMPLETE_AUTOMATION.md # Complete automation guide
```
### 🔄 Deployment Flow
**Complete Automation:**
```
GitHub Push → Webhook → Local Listener → SSH → Unraid VM → Deploy & Restart
```
**Manual Setup:**
```
GitHub Push → Webhook → Local Listener → SSH to VM → Deploy Script → Server Restart
```
## Features
- **Complete VM Automation**: Automatically creates VMs on Unraid
- **Automatic Deployment**: Deploys on push to main branch
- **Health Checks**: Verifies deployment success
- **Rollback Support**: Automatic rollback on deployment failure
- **Service Management**: Systemd integration for reliable service management
- **Database Setup**: Automated PostgreSQL configuration
- **Logging**: Comprehensive logging for debugging
- **Security**: SSH key authentication and webhook secrets
- **One-Command Setup**: Full automation with single script
## Usage
### Complete Automation (Recommended)
For Unraid users, run the complete automation:
```bash
./scripts/unraid/setup-complete-automation.sh
```
After setup, start the webhook listener:
```bash
./start-webhook.sh
```
### Local Development
Start the local development server:
```bash
./scripts/ci-start.sh
```
### VM Management (Unraid)
```bash
# Check VM status
python3 scripts/unraid/vm-manager.py status
# Start/stop VM
python3 scripts/unraid/vm-manager.py start
python3 scripts/unraid/vm-manager.py stop
# Get VM IP
python3 scripts/unraid/vm-manager.py ip
```
### Service Management
On the VM:
```bash
# Check status
ssh thrillwiki-vm "./scripts/vm-deploy.sh status"
# Restart service
ssh thrillwiki-vm "./scripts/vm-deploy.sh restart"
# View logs
ssh thrillwiki-vm "journalctl -u thrillwiki -f"
```
### Manual VM Deployment
Deploy to VM manually:
```bash
ssh thrillwiki-vm "cd thrillwiki && ./scripts/vm-deploy.sh"
```
## Configuration
### Automated Configuration
The complete automation script creates all necessary configuration files:
- `***REMOVED***.unraid` - Unraid VM configuration
- `***REMOVED***.webhook` - Webhook listener configuration
- SSH keys and configuration
- Service configurations
### Manual Environment Variables
For manual setup, create `***REMOVED***.webhook` file:
```bash
WEBHOOK_PORT=9000
WEBHOOK_SECRET=your_secret_here
VM_HOST=your_vm_ip
VM_USER=ubuntu
VM_KEY_PATH=/path/to/ssh/key
VM_PROJECT_PATH=/home/ubuntu/thrillwiki
REPO_URL=https://github.com/username/repo.git
DEPLOY_BRANCH=main
```
### GitHub Webhook
Configure in your GitHub repository:
- **URL**: `http://YOUR_PUBLIC_IP:9000/webhook`
- **Content Type**: `application/json`
- **Secret**: Your webhook secret
- **Events**: Push events
## Requirements
### For Complete Automation
- **Local Machine**: Python 3.8+, SSH client
- **Unraid Server**: 6.8+ with VM support
- **Resources**: 4GB RAM, 50GB disk minimum
- **Ubuntu ISO**: Ubuntu Server 22.04 in `/mnt/user/isos/`
### For Manual Setup
- **Local Machine**: Python 3.8+, SSH access to VM, Public IP
- **Linux VM**: Ubuntu 20.04+, Python 3.8+, UV package manager, Git, SSH server
## Troubleshooting
### Complete Automation Issues
1. **VM Creation Fails**
```bash
# Check Unraid VM support
ssh unraid "virsh list --all"
# Verify Ubuntu ISO exists
ssh unraid "ls -la /mnt/user/isos/ubuntu-*.iso"
```
2. **VM Won't Start**
```bash
# Check VM status
python3 scripts/unraid/vm-manager.py status
# Check Unraid logs
ssh unraid "tail -f /var/log/libvirt/qemu/thrillwiki-vm.log"
```
### General Issues
1. **SSH Connection Failed**
```bash
# Check SSH key permissions
chmod 600 ~/.ssh/thrillwiki_vm
# Test connection
ssh thrillwiki-vm
```
2. **Webhook Not Receiving Events**
```bash
# Check if port is open
sudo ufw allow 9000
# Verify webhook URL in GitHub
curl -X GET http://localhost:9000/health
```
3. **Service Won't Start**
```bash
# Check service logs
ssh thrillwiki-vm "journalctl -u thrillwiki --no-pager"
# Manual start
ssh thrillwiki-vm "cd thrillwiki && ./scripts/ci-start.sh"
```
### Logs
- **Setup logs**: `logs/unraid-automation.log`
- **Local webhook**: `logs/webhook.log`
- **VM deployment**: `logs/deploy.log` (on VM)
- **Django server**: `logs/django.log` (on VM)
- **System logs**: `journalctl -u thrillwiki -f` (on VM)
## Security Notes
- Automated SSH key generation and management
- Dedicated keys for each connection (VM access, Unraid access)
- No password authentication
- Systemd security features enabled
- Firewall configuration support
- Secret management in environment files
## Documentation
- **Complete Automation**: [`docs/UNRAID_COMPLETE_AUTOMATION.md`](docs/UNRAID_COMPLETE_AUTOMATION.md)
- **Manual Setup**: [`docs/VM_DEPLOYMENT_SETUP.md`](docs/VM_DEPLOYMENT_SETUP.md)
---
## Quick Start Summary
### For Unraid Users (Complete Automation)
```bash
# One command to set up everything
./scripts/unraid/setup-complete-automation.sh
# Start webhook listener
./start-webhook.sh
# Push commits to auto-deploy!
```
### For Existing VM Users
```bash
# Manual setup
./scripts/setup-vm-ci.sh
# Configure webhook and push to deploy
```
**The system will automatically deploy your Django application whenever you push commits to the main branch!** 🚀

443
README.md
View File

@@ -1,87 +1,200 @@
# ThrillWiki Backend
# ThrillWiki Django + Vue.js Monorepo
Django REST API backend for the ThrillWiki 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
## 🏗️ Architecture Overview
This backend follows Django best practices with a modular app structure:
This project uses a monorepo structure that cleanly separates backend and frontend concerns while maintaining shared resources and documentation:
```
backend/
├── apps/ # Django applications
│ ├── accounts/ # User management
│ ├── parks/ # Theme park data
│ ├── rides/ # Ride information
── moderation/ # Content moderation
│ ├── location/ # Geographic data
│ ├── media/ # File management
│ ├── email_service/ # Email functionality
│ └── core/ # Core utilities
├── config/ # Django configuration
│ ├── django/ # Settings files
── settings/ # Modular settings
├── templates/ # Django templates
├── static/ # Static files
── tests/ # Test files
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
```
## 🛠️ Technology Stack
- **Django 5.0+** - Web framework
- **Django REST Framework** - API framework
- **PostgreSQL** - Primary database
- **Redis** - Caching and sessions
- **UV** - Python package management
- **Celery** - Background task processing
## 🚀 Quick Start
### Prerequisites
- Python 3.11+
- [uv](https://docs.astral.sh/uv/) package manager
- PostgreSQL 14+
- Redis 6+
- **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)
### Setup
### Development Setup
1. **Install dependencies**
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 sync
```
2. **Environment configuration**
```bash
cp .env.example .env
# Edit .env with your settings
```
3. **Database setup**
```bash
uv run manage.py migrate
uv run manage.py createsuperuser
cd ..
```
4. **Start development server**
5. **Start development servers**
```bash
uv run manage.py runserver
# 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
Required environment variables:
#### Root `.env`
```bash
# Database
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
REDIS_URL=redis://localhost:6379
# Django
# 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
@@ -90,140 +203,142 @@ REDIS_URL=redis://localhost:6379
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
- **accounts** - User authentication and profile management
- **parks** - Theme park models and operations
- **rides** - Ride information and relationships
- **core** - Shared utilities and base classes
### Support Apps
- **moderation** - Content moderation workflows
- **location** - Geographic data and services
- **media** - File upload and management
- **email_service** - Email sending and templates
## 🔌 API Endpoints
Base URL: `http://localhost:8000/api/`
### Authentication
- `POST /auth/login/` - User login
- `POST /auth/logout/` - User logout
- `POST /auth/register/` - User registration
### Parks
- `GET /parks/` - List parks
- `GET /parks/{id}/` - Park details
- `POST /parks/` - Create park (admin)
### Rides
- `GET /rides/` - List rides
- `GET /rides/{id}/` - Ride details
- `GET /parks/{park_id}/rides/` - Rides by park
## 🧪 Testing
#### Frontend `.env.local`
```bash
# Run all tests
uv run manage.py test
# API Configuration
VITE_API_BASE_URL=http://localhost:8000/api
# Run specific app tests
uv run manage.py test apps.parks
# Development
VITE_APP_TITLE=ThrillWiki (Development)
# Run with coverage
uv run coverage run manage.py test
uv run coverage report
# Feature Flags
VITE_ENABLE_DEBUG=true
```
## 🔧 Management Commands
## 📊 Key Features
Custom management commands:
### 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
```bash
# Import park data
uv run manage.py import_parks data/parks.json
### 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
# Generate test data
uv run manage.py generate_test_data
## 📖 Documentation
# Clean up expired sessions
uv run manage.py clearsessions
```
### 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
## 📊 Database
### Architecture & Deployment
- **[Architecture Overview](./architecture/)** - System design and decisions
- **[Deployment Guide](./shared/docs/deployment/)** - Production deployment instructions
- **[Development Scripts](./shared/scripts/)** - Automation and tooling
### Entity Relationships
- **Parks** have Operators (required) and PropertyOwners (optional)
- **Rides** belong to Parks and may have Manufacturers/Designers
- **Users** can create submissions and moderate content
### Migrations
```bash
# Create migrations
uv run manage.py makemigrations
# Apply migrations
uv run manage.py migrate
# Show migration status
uv run manage.py showmigrations
```
## 🔐 Security
- CORS configured for frontend integration
- CSRF protection enabled
- JWT token authentication
- Rate limiting on API endpoints
- Input validation and sanitization
## 📈 Performance
- Database query optimization
- Redis caching for frequent queries
- Background task processing with Celery
- Database connection pooling
### 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
See the [Deployment Guide](../shared/docs/deployment/) for production setup.
### Development Environment
```bash
# Quick start with all services
./shared/scripts/dev/start-all.sh
## 🐛 Debugging
# Full development setup
./shared/scripts/dev/setup-dev.sh
```
### Development Tools
### Production Deployment
```bash
# Build all components
./shared/scripts/build/build-all.sh
- Django Debug Toolbar
- Django Extensions
- Silk profiler for performance analysis
# Deploy to production
./shared/scripts/deploy/deploy.sh
```
### Logging
See [Deployment Guide](./shared/docs/deployment/) for detailed production setup instructions.
Logs are written to:
- Console (development)
- Files in `logs/` directory (production)
- External logging service (production)
## 🧪 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
1. Follow Django coding standards
2. Write tests for new features
3. Update documentation
4. Run linting: `uv run flake8 .`
5. Format code: `uv run black .`
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**

326
TAILWIND_V4_MIGRATION.md Normal file
View File

@@ -0,0 +1,326 @@
# Tailwind CSS v3 to v4 Migration Documentation
## Overview
This document details the complete migration process from Tailwind CSS v3 to v4 for the Django ThrillWiki project. The migration was performed on August 15, 2025, and includes all changes, configurations, and verification steps.
## Migration Summary
- **From**: Tailwind CSS v3.x
- **To**: Tailwind CSS v4.1.12
- **Project**: Django ThrillWiki (Django + Tailwind CSS integration)
- **Status**: ✅ Complete and Verified
- **Breaking Changes**: None (all styling preserved)
## Key Changes in Tailwind CSS v4
### 1. CSS Import Syntax
- **v3**: Used `@tailwind` directives
- **v4**: Uses single `@import "tailwindcss"` statement
### 2. Theme Configuration
- **v3**: Configuration in `tailwind.config.js`
- **v4**: CSS-first approach with `@theme` blocks
### 3. Deprecated Utilities
Multiple utility classes were renamed or deprecated in v4.
## Migration Steps Performed
### Step 1: Update Main CSS File
**File**: `static/css/src/input.css`
**Before (v3)**:
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom styles... */
```
**After (v4)**:
```css
@import "tailwindcss";
@theme {
--color-primary: #4f46e5;
--color-secondary: #e11d48;
--color-accent: #8b5cf6;
--font-family-sans: Poppins, sans-serif;
}
/* Custom styles... */
```
### Step 2: Theme Variable Migration
Migrated custom colors and fonts from `tailwind.config.js` to CSS variables in `@theme` block:
| Variable | Value | Description |
|----------|-------|-------------|
| `--color-primary` | `#4f46e5` | Indigo-600 (primary brand color) |
| `--color-secondary` | `#e11d48` | Rose-600 (secondary brand color) |
| `--color-accent` | `#8b5cf6` | Violet-500 (accent color) |
| `--font-family-sans` | `Poppins, sans-serif` | Primary font family |
### Step 3: Deprecated Utility Updates
#### Outline Utilities
- **Changed**: `outline-none``outline-hidden`
- **Files affected**: All template files, component CSS
#### Ring Utilities
- **Changed**: `ring``ring-3`
- **Reason**: Default ring width now requires explicit specification
#### Shadow Utilities
- **Changed**:
- `shadow-sm``shadow-xs`
- `shadow``shadow-sm`
- **Files affected**: Button components, card components
#### Opacity Utilities
- **Changed**: `bg-opacity-*` format → `color/opacity` format
- **Example**: `bg-blue-500 bg-opacity-50``bg-blue-500/50`
#### Flex Utilities
- **Changed**: `flex-shrink-0``shrink-0`
#### Important Modifier
- **Changed**: `!important``!` (shorter syntax)
- **Example**: `!outline-none``!outline-hidden`
### Step 4: Template File Updates
Updated the following template files with new utility classes:
#### Core Templates
- `templates/base.html`
- `templates/components/navbar.html`
- `templates/components/footer.html`
#### Page Templates
- `templates/parks/park_list.html`
- `templates/parks/park_detail.html`
- `templates/rides/ride_list.html`
- `templates/rides/ride_detail.html`
- `templates/companies/company_list.html`
- `templates/companies/company_detail.html`
#### Form Templates
- `templates/parks/park_form.html`
- `templates/rides/ride_form.html`
- `templates/companies/company_form.html`
#### Component Templates
- `templates/components/search_results.html`
- `templates/components/pagination.html`
### Step 5: Component CSS Updates
Updated custom component classes in `static/css/src/input.css`:
**Button Components**:
```css
.btn-primary {
@apply inline-flex items-center px-6 py-2.5 border border-transparent rounded-full shadow-md text-sm font-medium text-white bg-gradient-to-r from-primary to-secondary hover:from-primary/90 hover:to-secondary/90 focus:outline-hidden focus:ring-3 focus:ring-offset-2 focus:ring-primary/50 transform hover:scale-105 transition-all;
}
.btn-secondary {
@apply inline-flex items-center px-6 py-2.5 border border-gray-200 dark:border-gray-700 rounded-full shadow-md text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-hidden focus:ring-3 focus:ring-offset-2 focus:ring-primary/50 transform hover:scale-105 transition-all;
}
```
## Configuration Files
### Tailwind Config (Preserved for Reference)
**File**: `tailwind.config.js`
The original v3 configuration was preserved for reference but is no longer the primary configuration method:
```javascript
module.exports = {
content: [
'./templates/**/*.html',
'./static/js/**/*.js',
'./*/templates/**/*.html',
],
darkMode: 'class',
theme: {
extend: {
colors: {
primary: '#4f46e5',
secondary: '#e11d48',
accent: '#8b5cf6',
},
fontFamily: {
sans: ['Poppins', 'sans-serif'],
},
},
},
plugins: [
require('@tailwindcss/forms'),
require('@tailwindcss/typography'),
],
}
```
### Package.json Updates
No changes required to `package.json` as the Django-Tailwind package handles version management.
## Verification Steps
### 1. Build Process Verification
```bash
# Clean and rebuild CSS
lsof -ti :8000 | xargs kill -9
find . -type d -name "__pycache__" -exec rm -r {} +
uv run manage.py tailwind runserver
```
**Result**: ✅ Build successful, no errors
### 2. CSS Compilation Check
```bash
# Check compiled CSS size and content
ls -la static/css/tailwind.css
head -50 static/css/tailwind.css | grep -E "(primary|secondary|accent)"
```
**Result**: ✅ CSS properly compiled with theme variables
### 3. Server Response Check
```bash
curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/
```
**Result**: ✅ HTTP 200 - Server responding correctly
### 4. Visual Verification
- ✅ Primary colors (indigo) displaying correctly
- ✅ Secondary colors (rose) displaying correctly
- ✅ Accent colors (violet) displaying correctly
- ✅ Poppins font family loading correctly
- ✅ Button styling and interactions working
- ✅ Dark mode functionality preserved
- ✅ Responsive design intact
- ✅ All animations and transitions working
## Files Modified
### CSS Files
- `static/css/src/input.css` - ✅ Major updates (import syntax, theme variables, component classes)
### Template Files (Updated utility classes)
- `templates/base.html`
- `templates/components/navbar.html`
- `templates/components/footer.html`
- `templates/parks/park_list.html`
- `templates/parks/park_detail.html`
- `templates/parks/park_form.html`
- `templates/rides/ride_list.html`
- `templates/rides/ride_detail.html`
- `templates/rides/ride_form.html`
- `templates/companies/company_list.html`
- `templates/companies/company_detail.html`
- `templates/companies/company_form.html`
- `templates/components/search_results.html`
- `templates/components/pagination.html`
### Configuration Files (Preserved)
- `tailwind.config.js` - ✅ Preserved for reference
## Benefits of v4 Migration
### Performance Improvements
- Smaller CSS bundle size
- Faster compilation times
- Improved CSS-in-JS performance
### Developer Experience
- CSS-first configuration approach
- Better IDE support for theme variables
- Simplified import syntax
### Future Compatibility
- Modern CSS features support
- Better container queries support
- Enhanced dark mode capabilities
## Troubleshooting Guide
### Common Issues and Solutions
#### Issue: "Cannot apply unknown utility class"
**Solution**: Check if utility was renamed in v4 migration table above
#### Issue: Custom colors not working
**Solution**: Ensure `@theme` block is properly defined with CSS variables
#### Issue: Build errors
**Solution**: Run clean build process:
```bash
lsof -ti :8000 | xargs kill -9
find . -type d -name "__pycache__" -exec rm -r {} +
uv run manage.py tailwind runserver
```
## Rollback Plan
If rollback is needed:
1. **Restore CSS Import Syntax**:
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```
2. **Remove @theme Block**: Delete the `@theme` section from input.css
3. **Revert Utility Classes**: Use search/replace to revert utility class changes
4. **Downgrade Tailwind**: Update package to v3.x version
## Post-Migration Checklist
- [x] CSS compilation working
- [x] Development server running
- [x] All pages loading correctly
- [x] Colors displaying properly
- [x] Fonts loading correctly
- [x] Interactive elements working
- [x] Dark mode functioning
- [x] Responsive design intact
- [x] No console errors
- [x] Performance acceptable
## Future Considerations
### New v4 Features to Explore
- Enhanced container queries
- Improved dark mode utilities
- New color-mix() support
- Advanced CSS nesting
### Maintenance Notes
- Monitor for v4 updates and new features
- Consider migrating more configuration to CSS variables
- Evaluate new utility classes as they're released
## Contact and Support
For questions about this migration:
- Review this documentation
- Check Tailwind CSS v4 official documentation
- Consult the preserved `tailwind.config.js` for original settings
---
**Migration Completed**: August 15, 2025
**Tailwind Version**: v4.1.12
**Status**: Production Ready ✅

View File

@@ -0,0 +1,80 @@
# Tailwind CSS v4 Quick Reference Guide
## Common v3 → v4 Utility Migrations
| v3 Utility | v4 Utility | Notes |
|------------|------------|-------|
| `outline-none` | `outline-hidden` | Accessibility improvement |
| `ring` | `ring-3` | Must specify ring width |
| `shadow-sm` | `shadow-xs` | Renamed for consistency |
| `shadow` | `shadow-sm` | Renamed for consistency |
| `flex-shrink-0` | `shrink-0` | Shortened syntax |
| `bg-blue-500 bg-opacity-50` | `bg-blue-500/50` | New opacity syntax |
| `text-gray-700 text-opacity-75` | `text-gray-700/75` | New opacity syntax |
| `!outline-none` | `!outline-hidden` | Updated important syntax |
## Theme Variables (Available in CSS)
```css
/* Colors */
var(--color-primary) /* #4f46e5 - Indigo-600 */
var(--color-secondary) /* #e11d48 - Rose-600 */
var(--color-accent) /* #8b5cf6 - Violet-500 */
/* Fonts */
var(--font-family-sans) /* Poppins, sans-serif */
```
## Usage in Templates
### Before (v3)
```html
<button class="outline-none ring hover:ring-2 shadow-sm bg-blue-500 bg-opacity-75">
Click me
</button>
```
### After (v4)
```html
<button class="outline-hidden ring-3 hover:ring-2 shadow-xs bg-blue-500/75">
Click me
</button>
```
## Development Commands
### Start Development Server
```bash
lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; uv run manage.py tailwind runserver
```
### Force CSS Rebuild
```bash
uv run manage.py tailwind build
```
## New v4 Features
- **CSS-first configuration** via `@theme` blocks
- **Improved opacity syntax** with `/` operator
- **Better color-mix() support**
- **Enhanced dark mode utilities**
- **Faster compilation**
## Troubleshooting
### Unknown utility class error
1. Check if utility was renamed (see table above)
2. Verify custom theme variables are defined
3. Run clean build process
### Colors not working
1. Ensure `@theme` block exists in `static/css/src/input.css`
2. Check CSS variable names match usage
3. Verify CSS compilation completed
## Resources
- [Full Migration Documentation](./TAILWIND_V4_MIGRATION.md)
- [Tailwind CSS v4 Official Docs](https://tailwindcss.com/docs)
- [Django-Tailwind Package](https://django-tailwind.readthedocs.io/)

View File

@@ -1,2 +0,0 @@
# Import choices to trigger registration
from .choices import *

View File

@@ -1,95 +0,0 @@
from django.conf import settings
from django.http import HttpRequest
from typing import Optional, Any, Dict, Literal, TYPE_CHECKING, cast
from allauth.account.adapter import DefaultAccountAdapter # type: ignore[import]
from allauth.account.models import EmailConfirmation, EmailAddress # type: ignore[import]
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter # type: ignore[import]
from allauth.socialaccount.models import SocialLogin # type: ignore[import]
from django.contrib.auth import get_user_model
from django.contrib.sites.shortcuts import get_current_site
if TYPE_CHECKING:
from django.contrib.auth.models import AbstractUser
User = get_user_model()
class CustomAccountAdapter(DefaultAccountAdapter):
def is_open_for_signup(self, request: HttpRequest) -> Literal[True]:
"""
Whether to allow sign ups.
"""
return True
def get_email_confirmation_url(self, request: HttpRequest, emailconfirmation: EmailConfirmation) -> str:
"""
Constructs the email confirmation (activation) url.
"""
get_current_site(request)
# Ensure the key is treated as a string for the type checker
key = cast(str, getattr(emailconfirmation, "key", ""))
return f"{settings.LOGIN_REDIRECT_URL}verify-email?key={key}"
def send_confirmation_mail(self, request: HttpRequest, emailconfirmation: EmailConfirmation, signup: bool) -> None:
"""
Sends the confirmation email.
"""
current_site = get_current_site(request)
activate_url = self.get_email_confirmation_url(request, emailconfirmation)
# Cast key to str for typing consistency and template context
key = cast(str, getattr(emailconfirmation, "key", ""))
# Determine template early
if signup:
email_template = "account/email/email_confirmation_signup"
else:
email_template = "account/email/email_confirmation"
# Cast the possibly-unknown email_address to EmailAddress so the type checker knows its attributes
email_address = cast(EmailAddress, getattr(emailconfirmation, "email_address", None))
# Safely obtain email string (fallback to any top-level email on confirmation)
email_str = cast(str, getattr(email_address, "email", getattr(emailconfirmation, "email", "")))
# Safely obtain the user object, cast to the project's User model for typing
user_obj = cast("AbstractUser", getattr(email_address, "user", None))
# Explicitly type the context to avoid partial-unknown typing issues
ctx: Dict[str, Any] = {
"user": user_obj,
"activate_url": activate_url,
"current_site": current_site,
"key": key,
}
# Remove unnecessary cast; ctx is already Dict[str, Any]
self.send_mail(email_template, email_str, ctx) # type: ignore
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
def is_open_for_signup(self, request: HttpRequest, sociallogin: SocialLogin) -> Literal[True]:
"""
Whether to allow social account sign ups.
"""
return True
def populate_user(
self, request: HttpRequest, sociallogin: SocialLogin, data: Dict[str, Any]
) -> "AbstractUser": # type: ignore[override]
"""
Hook that can be used to further populate the user instance.
"""
user = super().populate_user(request, sociallogin, data) # type: ignore
if getattr(sociallogin.account, "provider", None) == "discord": # type: ignore
user.discord_id = getattr(sociallogin.account, "uid", None) # type: ignore
return cast("AbstractUser", user) # Ensure return type is explicit
def save_user(
self, request: HttpRequest, sociallogin: SocialLogin, form: Optional[Any] = None
) -> "AbstractUser": # type: ignore[override]
"""
Save the newly signed up social login.
"""
user = super().save_user(request, sociallogin, form) # type: ignore
if user is None:
raise ValueError("User creation failed")
return cast("AbstractUser", user) # Ensure return type is explicit

View File

@@ -1,563 +0,0 @@
"""
Rich Choice Objects for Accounts Domain
This module defines all choice objects used in the accounts domain,
replacing tuple-based choices with rich, metadata-enhanced choice objects.
Last updated: 2025-01-15
"""
from apps.core.choices import RichChoice, ChoiceGroup, register_choices
# =============================================================================
# USER ROLES
# =============================================================================
user_roles = ChoiceGroup(
name="user_roles",
choices=[
RichChoice(
value="USER",
label="User",
description="Standard user with basic permissions to create content, reviews, and lists",
metadata={
"color": "blue",
"icon": "user",
"css_class": "text-blue-600 bg-blue-50",
"permissions": ["create_content", "create_reviews", "create_lists"],
"sort_order": 1,
}
),
RichChoice(
value="MODERATOR",
label="Moderator",
description="Trusted user with permissions to moderate content and assist other users",
metadata={
"color": "green",
"icon": "shield-check",
"css_class": "text-green-600 bg-green-50",
"permissions": ["moderate_content", "review_submissions", "manage_reports"],
"sort_order": 2,
}
),
RichChoice(
value="ADMIN",
label="Admin",
description="Administrator with elevated permissions to manage users and site configuration",
metadata={
"color": "purple",
"icon": "cog",
"css_class": "text-purple-600 bg-purple-50",
"permissions": ["manage_users", "site_configuration", "advanced_moderation"],
"sort_order": 3,
}
),
RichChoice(
value="SUPERUSER",
label="Superuser",
description="Full system administrator with unrestricted access to all features",
metadata={
"color": "red",
"icon": "key",
"css_class": "text-red-600 bg-red-50",
"permissions": ["full_access", "system_administration", "database_access"],
"sort_order": 4,
}
),
]
)
# =============================================================================
# THEME PREFERENCES
# =============================================================================
theme_preferences = ChoiceGroup(
name="theme_preferences",
choices=[
RichChoice(
value="light",
label="Light",
description="Light theme with bright backgrounds and dark text for daytime use",
metadata={
"color": "yellow",
"icon": "sun",
"css_class": "text-yellow-600 bg-yellow-50",
"preview_colors": {
"background": "#ffffff",
"text": "#1f2937",
"accent": "#3b82f6"
},
"sort_order": 1,
}
),
RichChoice(
value="dark",
label="Dark",
description="Dark theme with dark backgrounds and light text for nighttime use",
metadata={
"color": "gray",
"icon": "moon",
"css_class": "text-gray-600 bg-gray-50",
"preview_colors": {
"background": "#1f2937",
"text": "#f9fafb",
"accent": "#60a5fa"
},
"sort_order": 2,
}
),
]
)
# =============================================================================
# PRIVACY LEVELS
# =============================================================================
privacy_levels = ChoiceGroup(
name="privacy_levels",
choices=[
RichChoice(
value="public",
label="Public",
description="Profile and activity visible to all users and search engines",
metadata={
"color": "green",
"icon": "globe",
"css_class": "text-green-600 bg-green-50",
"visibility_scope": "everyone",
"search_indexable": True,
"implications": [
"Profile visible to all users",
"Activity appears in public feeds",
"Searchable by search engines",
"Can be found by username search"
],
"sort_order": 1,
}
),
RichChoice(
value="friends",
label="Friends Only",
description="Profile and activity visible only to accepted friends",
metadata={
"color": "blue",
"icon": "users",
"css_class": "text-blue-600 bg-blue-50",
"visibility_scope": "friends",
"search_indexable": False,
"implications": [
"Profile visible only to friends",
"Activity hidden from public feeds",
"Not searchable by search engines",
"Requires friend request approval"
],
"sort_order": 2,
}
),
RichChoice(
value="private",
label="Private",
description="Profile and activity completely private, visible only to you",
metadata={
"color": "red",
"icon": "lock",
"css_class": "text-red-600 bg-red-50",
"visibility_scope": "self",
"search_indexable": False,
"implications": [
"Profile completely hidden",
"No activity in any feeds",
"Not discoverable by other users",
"Maximum privacy protection"
],
"sort_order": 3,
}
),
]
)
# =============================================================================
# TOP LIST CATEGORIES
# =============================================================================
top_list_categories = ChoiceGroup(
name="top_list_categories",
choices=[
RichChoice(
value="RC",
label="Roller Coaster",
description="Top lists for roller coasters and thrill rides",
metadata={
"color": "red",
"icon": "roller-coaster",
"css_class": "text-red-600 bg-red-50",
"ride_category": "roller_coaster",
"typical_list_size": 10,
"sort_order": 1,
}
),
RichChoice(
value="DR",
label="Dark Ride",
description="Top lists for dark rides and indoor attractions",
metadata={
"color": "purple",
"icon": "moon",
"css_class": "text-purple-600 bg-purple-50",
"ride_category": "dark_ride",
"typical_list_size": 10,
"sort_order": 2,
}
),
RichChoice(
value="FR",
label="Flat Ride",
description="Top lists for flat rides and spinning attractions",
metadata={
"color": "blue",
"icon": "refresh",
"css_class": "text-blue-600 bg-blue-50",
"ride_category": "flat_ride",
"typical_list_size": 10,
"sort_order": 3,
}
),
RichChoice(
value="WR",
label="Water Ride",
description="Top lists for water rides and splash attractions",
metadata={
"color": "cyan",
"icon": "droplet",
"css_class": "text-cyan-600 bg-cyan-50",
"ride_category": "water_ride",
"typical_list_size": 10,
"sort_order": 4,
}
),
RichChoice(
value="PK",
label="Park",
description="Top lists for theme parks and amusement parks",
metadata={
"color": "green",
"icon": "map",
"css_class": "text-green-600 bg-green-50",
"entity_type": "park",
"typical_list_size": 10,
"sort_order": 5,
}
),
]
)
# =============================================================================
# NOTIFICATION TYPES
# =============================================================================
notification_types = ChoiceGroup(
name="notification_types",
choices=[
# Submission related
RichChoice(
value="submission_approved",
label="Submission Approved",
description="Notification when user's submission is approved by moderators",
metadata={
"color": "green",
"icon": "check-circle",
"css_class": "text-green-600 bg-green-50",
"category": "submission",
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 1,
}
),
RichChoice(
value="submission_rejected",
label="Submission Rejected",
description="Notification when user's submission is rejected by moderators",
metadata={
"color": "red",
"icon": "x-circle",
"css_class": "text-red-600 bg-red-50",
"category": "submission",
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 2,
}
),
RichChoice(
value="submission_pending",
label="Submission Pending Review",
description="Notification when user's submission is pending moderator review",
metadata={
"color": "yellow",
"icon": "clock",
"css_class": "text-yellow-600 bg-yellow-50",
"category": "submission",
"default_channels": ["inapp"],
"priority": "low",
"sort_order": 3,
}
),
# Review related
RichChoice(
value="review_reply",
label="Review Reply",
description="Notification when someone replies to user's review",
metadata={
"color": "blue",
"icon": "chat-bubble",
"css_class": "text-blue-600 bg-blue-50",
"category": "review",
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 4,
}
),
RichChoice(
value="review_helpful",
label="Review Marked Helpful",
description="Notification when user's review is marked as helpful",
metadata={
"color": "green",
"icon": "thumbs-up",
"css_class": "text-green-600 bg-green-50",
"category": "review",
"default_channels": ["push", "inapp"],
"priority": "low",
"sort_order": 5,
}
),
# Social related
RichChoice(
value="friend_request",
label="Friend Request",
description="Notification when user receives a friend request",
metadata={
"color": "blue",
"icon": "user-plus",
"css_class": "text-blue-600 bg-blue-50",
"category": "social",
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 6,
}
),
RichChoice(
value="friend_accepted",
label="Friend Request Accepted",
description="Notification when user's friend request is accepted",
metadata={
"color": "green",
"icon": "user-check",
"css_class": "text-green-600 bg-green-50",
"category": "social",
"default_channels": ["push", "inapp"],
"priority": "low",
"sort_order": 7,
}
),
RichChoice(
value="message_received",
label="Message Received",
description="Notification when user receives a private message",
metadata={
"color": "blue",
"icon": "mail",
"css_class": "text-blue-600 bg-blue-50",
"category": "social",
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 8,
}
),
RichChoice(
value="profile_comment",
label="Profile Comment",
description="Notification when someone comments on user's profile",
metadata={
"color": "blue",
"icon": "chat",
"css_class": "text-blue-600 bg-blue-50",
"category": "social",
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 9,
}
),
# System related
RichChoice(
value="system_announcement",
label="System Announcement",
description="Important announcements from the ThrillWiki team",
metadata={
"color": "purple",
"icon": "megaphone",
"css_class": "text-purple-600 bg-purple-50",
"category": "system",
"default_channels": ["email", "inapp"],
"priority": "normal",
"sort_order": 10,
}
),
RichChoice(
value="account_security",
label="Account Security",
description="Security-related notifications for user's account",
metadata={
"color": "red",
"icon": "shield-exclamation",
"css_class": "text-red-600 bg-red-50",
"category": "system",
"default_channels": ["email", "push", "inapp"],
"priority": "high",
"sort_order": 11,
}
),
RichChoice(
value="feature_update",
label="Feature Update",
description="Notifications about new features and improvements",
metadata={
"color": "blue",
"icon": "sparkles",
"css_class": "text-blue-600 bg-blue-50",
"category": "system",
"default_channels": ["email", "inapp"],
"priority": "low",
"sort_order": 12,
}
),
RichChoice(
value="maintenance",
label="Maintenance Notice",
description="Scheduled maintenance and downtime notifications",
metadata={
"color": "yellow",
"icon": "wrench",
"css_class": "text-yellow-600 bg-yellow-50",
"category": "system",
"default_channels": ["email", "inapp"],
"priority": "normal",
"sort_order": 13,
}
),
# Achievement related
RichChoice(
value="achievement_unlocked",
label="Achievement Unlocked",
description="Notification when user unlocks a new achievement",
metadata={
"color": "gold",
"icon": "trophy",
"css_class": "text-yellow-600 bg-yellow-50",
"category": "achievement",
"default_channels": ["push", "inapp"],
"priority": "low",
"sort_order": 14,
}
),
RichChoice(
value="milestone_reached",
label="Milestone Reached",
description="Notification when user reaches a significant milestone",
metadata={
"color": "purple",
"icon": "flag",
"css_class": "text-purple-600 bg-purple-50",
"category": "achievement",
"default_channels": ["push", "inapp"],
"priority": "low",
"sort_order": 15,
}
),
]
)
# =============================================================================
# NOTIFICATION PRIORITIES
# =============================================================================
notification_priorities = ChoiceGroup(
name="notification_priorities",
choices=[
RichChoice(
value="low",
label="Low",
description="Low priority notifications that can be delayed or batched",
metadata={
"color": "gray",
"icon": "arrow-down",
"css_class": "text-gray-600 bg-gray-50",
"urgency_level": 1,
"batch_eligible": True,
"delay_minutes": 60,
"sort_order": 1,
}
),
RichChoice(
value="normal",
label="Normal",
description="Standard priority notifications sent in regular intervals",
metadata={
"color": "blue",
"icon": "minus",
"css_class": "text-blue-600 bg-blue-50",
"urgency_level": 2,
"batch_eligible": True,
"delay_minutes": 15,
"sort_order": 2,
}
),
RichChoice(
value="high",
label="High",
description="High priority notifications sent immediately",
metadata={
"color": "orange",
"icon": "arrow-up",
"css_class": "text-orange-600 bg-orange-50",
"urgency_level": 3,
"batch_eligible": False,
"delay_minutes": 0,
"sort_order": 3,
}
),
RichChoice(
value="urgent",
label="Urgent",
description="Critical notifications requiring immediate attention",
metadata={
"color": "red",
"icon": "exclamation",
"css_class": "text-red-600 bg-red-50",
"urgency_level": 4,
"batch_eligible": False,
"delay_minutes": 0,
"bypass_preferences": True,
"sort_order": 4,
}
),
]
)
# =============================================================================
# REGISTER ALL CHOICE GROUPS
# =============================================================================
# Register each choice group individually
register_choices("user_roles", user_roles.choices, "accounts", "User role classifications")
register_choices("theme_preferences", theme_preferences.choices, "accounts", "Theme preference options")
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("notification_types", notification_types.choices, "accounts", "Notification type classifications")
register_choices("notification_priorities", notification_priorities.choices, "accounts", "Notification priority levels")

View File

@@ -1,164 +0,0 @@
"""
Django management command to delete a user while preserving their submissions.
Usage:
uv run manage.py delete_user <username>
uv run manage.py delete_user --user-id <user_id>
uv run manage.py delete_user <username> --dry-run
"""
from django.core.management.base import BaseCommand, CommandError
from apps.accounts.models import User
from apps.accounts.services import UserDeletionService
class Command(BaseCommand):
help = "Delete a user while preserving all their submissions"
def add_arguments(self, parser):
parser.add_argument(
"username", nargs="?", type=str, help="Username of the user to delete"
)
parser.add_argument(
"--user-id",
type=str,
help="User ID of the user to delete (alternative to username)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be deleted without actually deleting",
)
parser.add_argument(
"--force", action="store_true", help="Skip confirmation prompt"
)
def handle(self, *args, **options):
username = options.get("username")
user_id = options.get("user_id")
dry_run = options.get("dry_run", False)
force = options.get("force", False)
# Validate arguments
if not username and not user_id:
raise CommandError("You must provide either a username or --user-id")
if username and user_id:
raise CommandError("You cannot provide both username and --user-id")
# Find the user
try:
if username:
user = User.objects.get(username=username)
else:
user = User.objects.get(user_id=user_id)
except User.DoesNotExist:
identifier = username or user_id
raise CommandError(f'User "{identifier}" does not exist')
# Check if user can be deleted
can_delete, reason = UserDeletionService.can_delete_user(user)
if not can_delete:
raise CommandError(f"Cannot delete user: {reason}")
# Count submissions
submission_counts = {
"park_reviews": getattr(
user, "park_reviews", user.__class__.objects.none()
).count(),
"ride_reviews": getattr(
user, "ride_reviews", user.__class__.objects.none()
).count(),
"uploaded_park_photos": getattr(
user, "uploaded_park_photos", user.__class__.objects.none()
).count(),
"uploaded_ride_photos": getattr(
user, "uploaded_ride_photos", user.__class__.objects.none()
).count(),
"top_lists": getattr(
user, "top_lists", user.__class__.objects.none()
).count(),
"edit_submissions": getattr(
user, "edit_submissions", user.__class__.objects.none()
).count(),
"photo_submissions": getattr(
user, "photo_submissions", user.__class__.objects.none()
).count(),
}
total_submissions = sum(submission_counts.values())
# Display user information
self.stdout.write(self.style.WARNING("\nUser Information:"))
self.stdout.write(f" Username: {user.username}")
self.stdout.write(f" User ID: {user.user_id}")
self.stdout.write(f" Email: {user.email}")
self.stdout.write(f" Date Joined: {user.date_joined}")
self.stdout.write(f" Role: {user.role}")
# Display submission counts
self.stdout.write(self.style.WARNING("\nSubmissions to preserve:"))
for submission_type, count in submission_counts.items():
if count > 0:
self.stdout.write(
f' {submission_type.replace("_", " ").title()}: {count}'
)
self.stdout.write(f"\nTotal submissions: {total_submissions}")
if total_submissions > 0:
self.stdout.write(
self.style.SUCCESS(
f'\nAll {total_submissions} submissions will be transferred to the "deleted_user" placeholder.'
)
)
else:
self.stdout.write(
self.style.WARNING("\nNo submissions found for this user.")
)
if dry_run:
self.stdout.write(self.style.SUCCESS("\n[DRY RUN] No changes were made."))
return
# Confirmation prompt
if not force:
self.stdout.write(
self.style.WARNING(
f'\nThis will permanently delete the user "{user.username}" '
f"but preserve all {total_submissions} submissions."
)
)
confirm = input("Are you sure you want to continue? (yes/no): ")
if confirm.lower() not in ["yes", "y"]:
self.stdout.write(self.style.ERROR("Operation cancelled."))
return
# Perform the deletion
try:
result = UserDeletionService.delete_user_preserve_submissions(user)
self.stdout.write(
self.style.SUCCESS(
f'\nSuccessfully deleted user "{result["deleted_user"]["username"]}"'
)
)
preserved_count = sum(result["preserved_submissions"].values())
if preserved_count > 0:
self.stdout.write(
self.style.SUCCESS(
f'Preserved {preserved_count} submissions under user "{result["transferred_to"]["username"]}"'
)
)
# Show detailed preservation summary
self.stdout.write(self.style.WARNING("\nPreservation Summary:"))
for submission_type, count in result["preserved_submissions"].items():
if count > 0:
self.stdout.write(
f' {submission_type.replace("_", " ").title()}: {count}'
)
except Exception as e:
raise CommandError(f"Error deleting user: {str(e)}")

File diff suppressed because it is too large Load Diff

View File

@@ -1,76 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-21 01:29
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="userprofile",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="userprofile",
name="update_update",
),
migrations.AddField(
model_name="userprofile",
name="avatar",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
migrations.AddField(
model_name="userprofileevent",
name="avatar",
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
pgtrigger.migrations.AddTrigger(
model_name="userprofile",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_userprofileevent" ("avatar_id", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar_id", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
hash="a7ecdb1ac2821dea1fef4ec917eeaf6b8e4f09c8",
operation="INSERT",
pgid="pgtrigger_insert_insert_c09d7",
table="accounts_userprofile",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="userprofile",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_userprofileevent" ("avatar_id", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar_id", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
hash="81607e492ffea2a4c741452b860ee660374cc01d",
operation="UPDATE",
pgid="pgtrigger_update_update_87ef6",
table="accounts_userprofile",
when="AFTER",
),
),
),
]

View File

@@ -1,636 +0,0 @@
from django.dispatch import receiver
from django.db.models.signals import post_save
from django.contrib.auth.models import AbstractUser
from django.contrib.contenttypes.fields import GenericForeignKey
from django.db import models
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 apps.core.history import TrackedModel
from apps.core.choices import RichChoiceField
import pghistory
def generate_random_id(model_class, id_field):
"""Generate a random ID starting at 4 digits, expanding to 5 if needed"""
while True:
# Try to get a 4-digit number first
new_id = str(secrets.SystemRandom().randint(1000, 9999))
if not model_class.objects.filter(**{id_field: new_id}).exists():
return new_id
# If all 4-digit numbers are taken, try 5 digits
new_id = str(secrets.SystemRandom().randint(10000, 99999))
if not model_class.objects.filter(**{id_field: new_id}).exists():
return new_id
@pghistory.track()
class User(AbstractUser):
# Override inherited fields to remove them
first_name = None
last_name = None
# Read-only ID
user_id = models.CharField(
max_length=10,
unique=True,
editable=False,
help_text=(
"Unique identifier for this user that remains constant even if the "
"username changes"
),
)
role = RichChoiceField(
choice_group="user_roles",
domain="accounts",
max_length=10,
default="USER",
)
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)
theme_preference = RichChoiceField(
choice_group="theme_preferences",
domain="accounts",
max_length=5,
default="light",
)
# Notification preferences
email_notifications = models.BooleanField(default=True)
push_notifications = models.BooleanField(default=False)
# Privacy settings
privacy_level = RichChoiceField(
choice_group="privacy_levels",
domain="accounts",
max_length=10,
default="public",
)
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(
choice_group="privacy_levels",
domain="accounts",
max_length=10,
default="friends",
)
# Security settings
two_factor_enabled = models.BooleanField(default=False)
login_notifications = models.BooleanField(default=True)
session_timeout = models.IntegerField(default=30) # days
login_history_retention = models.IntegerField(default=90) # days
last_password_change = models.DateTimeField(auto_now_add=True)
# Display name - core user data for better performance
display_name = models.CharField(
max_length=50,
blank=True,
help_text="Display name shown throughout the site. Falls back to username if not set.",
)
# Detailed notification preferences (JSON field for flexibility)
notification_preferences = models.JSONField(
default=dict,
blank=True,
help_text="Detailed notification preferences stored as JSON",
)
def __str__(self):
return self.get_display_name()
def get_absolute_url(self):
return reverse("profile", kwargs={"username": self.username})
def get_display_name(self):
"""Get the user's display name, falling back to username if not set"""
if self.display_name:
return self.display_name
return self.username
def save(self, *args, **kwargs):
if not self.user_id:
self.user_id = generate_random_id(User, "user_id")
super().save(*args, **kwargs)
@pghistory.track()
class UserProfile(models.Model):
# Read-only ID
profile_id = models.CharField(
max_length=10,
unique=True,
editable=False,
help_text="Unique identifier for this profile that remains constant",
)
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
display_name = models.CharField(
max_length=50,
blank=True,
help_text="Legacy display name field - use User.display_name instead",
)
avatar = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
on_delete=models.SET_NULL,
null=True,
blank=True
)
pronouns = models.CharField(max_length=50, blank=True)
bio = models.TextField(max_length=500, blank=True)
# Social media links
twitter = models.URLField(blank=True)
instagram = models.URLField(blank=True)
youtube = models.URLField(blank=True)
discord = models.CharField(max_length=100, blank=True)
# Ride statistics
coaster_credits = models.IntegerField(default=0)
dark_ride_credits = models.IntegerField(default=0)
flat_ride_credits = models.IntegerField(default=0)
water_ride_credits = models.IntegerField(default=0)
def get_avatar_url(self):
"""
Return the avatar URL or generate a default letter-based avatar URL
"""
if self.avatar and self.avatar.is_uploaded:
# Try to get avatar variant first, fallback to public
avatar_url = self.avatar.get_url('avatar')
if avatar_url:
return avatar_url
# Fallback to public variant
public_url = self.avatar.get_url('public')
if public_url:
return public_url
# Last fallback - try any available variant
if self.avatar.variants:
if isinstance(self.avatar.variants, list) and self.avatar.variants:
return self.avatar.variants[0]
elif isinstance(self.avatar.variants, dict):
# Return first available variant
for variant_url in self.avatar.variants.values():
if variant_url:
return variant_url
# Generate default letter-based avatar using first letter of username
first_letter = self.user.username[0].upper() if self.user.username else "U"
# Use a service like UI Avatars or generate a simple colored avatar
return f"https://ui-avatars.com/api/?name={first_letter}&size=200&background=random&color=fff&bold=true"
def get_avatar_variants(self):
"""
Return avatar variants for different use cases
"""
if self.avatar and self.avatar.is_uploaded:
variants = {}
# Try to get specific variants
thumbnail_url = self.avatar.get_url('thumbnail')
avatar_url = self.avatar.get_url('avatar')
large_url = self.avatar.get_url('large')
public_url = self.avatar.get_url('public')
# Use specific variants if available, otherwise fallback to public or first available
fallback_url = public_url
if not fallback_url and self.avatar.variants:
if isinstance(self.avatar.variants, list) and self.avatar.variants:
fallback_url = self.avatar.variants[0]
elif isinstance(self.avatar.variants, dict):
fallback_url = next(iter(self.avatar.variants.values()), None)
variants = {
"thumbnail": thumbnail_url or fallback_url,
"avatar": avatar_url or fallback_url,
"large": large_url or fallback_url,
}
# Only return variants if we have at least one valid URL
if any(variants.values()):
return variants
# For default avatars, return the same URL for all variants
default_url = self.get_avatar_url()
return {
"thumbnail": default_url,
"avatar": default_url,
"large": default_url,
}
def save(self, *args, **kwargs):
# If no display name is set, use the username
if not self.display_name:
self.display_name = self.user.username
if not self.profile_id:
self.profile_id = generate_random_id(UserProfile, "profile_id")
super().save(*args, **kwargs)
def __str__(self):
return self.display_name
@pghistory.track()
class EmailVerification(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
token = models.CharField(max_length=64, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
last_sent = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Email verification for {self.user.username}"
class Meta:
verbose_name = "Email Verification"
verbose_name_plural = "Email Verifications"
@pghistory.track()
class PasswordReset(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
token = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
used = models.BooleanField(default=False)
def __str__(self):
return f"Password reset for {self.user.username}"
class Meta:
verbose_name = "Password Reset"
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()
class UserDeletionRequest(models.Model):
"""
Model to track user deletion requests with email verification.
When a user requests to delete their account, a verification code
is sent to their email. The deletion is only processed when they
provide the correct code.
"""
user = models.OneToOneField(
User, on_delete=models.CASCADE, related_name="deletion_request"
)
verification_code = models.CharField(
max_length=32,
unique=True,
help_text="Unique verification code sent to user's email",
)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(help_text="When this deletion request expires")
email_sent_at = models.DateTimeField(
null=True, blank=True, help_text="When the verification email was sent"
)
attempts = models.PositiveIntegerField(
default=0, help_text="Number of verification attempts made"
)
max_attempts = models.PositiveIntegerField(
default=5, help_text="Maximum number of verification attempts allowed"
)
is_used = models.BooleanField(
default=False, help_text="Whether this deletion request has been used"
)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["verification_code"]),
models.Index(fields=["expires_at"]),
models.Index(fields=["user", "is_used"]),
]
def __str__(self):
return f"Deletion request for {self.user.username} - {self.verification_code}"
def save(self, *args, **kwargs):
if not self.verification_code:
self.verification_code = self.generate_verification_code()
if not self.expires_at:
# Deletion requests expire after 24 hours
self.expires_at = timezone.now() + timedelta(hours=24)
super().save(*args, **kwargs)
@staticmethod
def generate_verification_code():
"""Generate a unique 8-character verification code."""
while True:
# Generate a random 8-character alphanumeric code
code = "".join(
secrets.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(8)
)
# Ensure it's unique
if not UserDeletionRequest.objects.filter(verification_code=code).exists():
return code
def is_expired(self):
"""Check if this deletion request has expired."""
return timezone.now() > self.expires_at
def is_valid(self):
"""Check if this deletion request is still valid."""
return (
not self.is_used
and not self.is_expired()
and self.attempts < self.max_attempts
)
def increment_attempts(self):
"""Increment the number of verification attempts."""
self.attempts += 1
self.save(update_fields=["attempts"])
def mark_as_used(self):
"""Mark this deletion request as used."""
self.is_used = True
self.save(update_fields=["is_used"])
@classmethod
def cleanup_expired(cls):
"""Remove expired deletion requests."""
expired_requests = cls.objects.filter(
expires_at__lt=timezone.now(), is_used=False
)
count = expired_requests.count()
expired_requests.delete()
return count
@pghistory.track()
class UserNotification(TrackedModel):
"""
Model to store user notifications for various events.
This includes submission approvals, rejections, system announcements,
and other user-relevant notifications.
"""
# Core fields
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="notifications"
)
notification_type = RichChoiceField(
choice_group="notification_types",
domain="accounts",
max_length=30,
)
title = models.CharField(max_length=200)
message = models.TextField()
# Optional related object (submission, review, etc.)
content_type = models.ForeignKey(
"contenttypes.ContentType", on_delete=models.CASCADE, null=True, blank=True
)
object_id = models.PositiveIntegerField(null=True, blank=True)
related_object = GenericForeignKey("content_type", "object_id")
# Metadata
priority = RichChoiceField(
choice_group="notification_priorities",
domain="accounts",
max_length=10,
default="normal",
)
# Status tracking
is_read = models.BooleanField(default=False)
read_at = models.DateTimeField(null=True, blank=True)
# Delivery tracking
email_sent = models.BooleanField(default=False)
email_sent_at = models.DateTimeField(null=True, blank=True)
push_sent = models.BooleanField(default=False)
push_sent_at = models.DateTimeField(null=True, blank=True)
# Additional data (JSON field for flexibility)
extra_data = models.JSONField(default=dict, blank=True)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(null=True, blank=True)
class Meta(TrackedModel.Meta):
ordering = ["-created_at"]
indexes = [
models.Index(fields=["user", "is_read"]),
models.Index(fields=["user", "notification_type"]),
models.Index(fields=["created_at"]),
models.Index(fields=["expires_at"]),
]
def __str__(self):
return f"{self.user.username}: {self.title}"
def mark_as_read(self):
"""Mark notification as read."""
if not self.is_read:
self.is_read = True
self.read_at = timezone.now()
self.save(update_fields=["is_read", "read_at"])
def is_expired(self):
"""Check if notification has expired."""
if not self.expires_at:
return False
return timezone.now() > self.expires_at
@classmethod
def cleanup_expired(cls):
"""Remove expired notifications."""
expired_notifications = cls.objects.filter(expires_at__lt=timezone.now())
count = expired_notifications.count()
expired_notifications.delete()
return count
@classmethod
def mark_all_read_for_user(cls, user):
"""Mark all notifications as read for a specific user."""
return cls.objects.filter(user=user, is_read=False).update(
is_read=True, read_at=timezone.now()
)
@pghistory.track()
class NotificationPreference(TrackedModel):
"""
User preferences for different types of notifications.
This allows users to control which notifications they receive
and through which channels (email, push, in-app).
"""
user = models.OneToOneField(
User, on_delete=models.CASCADE, related_name="notification_preference"
)
# Submission notifications
submission_approved_email = models.BooleanField(default=True)
submission_approved_push = models.BooleanField(default=True)
submission_approved_inapp = models.BooleanField(default=True)
submission_rejected_email = models.BooleanField(default=True)
submission_rejected_push = models.BooleanField(default=True)
submission_rejected_inapp = models.BooleanField(default=True)
submission_pending_email = models.BooleanField(default=False)
submission_pending_push = models.BooleanField(default=False)
submission_pending_inapp = models.BooleanField(default=True)
# Review notifications
review_reply_email = models.BooleanField(default=True)
review_reply_push = models.BooleanField(default=True)
review_reply_inapp = models.BooleanField(default=True)
review_helpful_email = models.BooleanField(default=False)
review_helpful_push = models.BooleanField(default=True)
review_helpful_inapp = models.BooleanField(default=True)
# Social notifications
friend_request_email = models.BooleanField(default=True)
friend_request_push = models.BooleanField(default=True)
friend_request_inapp = models.BooleanField(default=True)
friend_accepted_email = models.BooleanField(default=False)
friend_accepted_push = models.BooleanField(default=True)
friend_accepted_inapp = models.BooleanField(default=True)
message_received_email = models.BooleanField(default=True)
message_received_push = models.BooleanField(default=True)
message_received_inapp = models.BooleanField(default=True)
# System notifications
system_announcement_email = models.BooleanField(default=True)
system_announcement_push = models.BooleanField(default=False)
system_announcement_inapp = models.BooleanField(default=True)
account_security_email = models.BooleanField(default=True)
account_security_push = models.BooleanField(default=True)
account_security_inapp = models.BooleanField(default=True)
feature_update_email = models.BooleanField(default=True)
feature_update_push = models.BooleanField(default=False)
feature_update_inapp = models.BooleanField(default=True)
# Achievement notifications
achievement_unlocked_email = models.BooleanField(default=False)
achievement_unlocked_push = models.BooleanField(default=True)
achievement_unlocked_inapp = models.BooleanField(default=True)
milestone_reached_email = models.BooleanField(default=False)
milestone_reached_push = models.BooleanField(default=True)
milestone_reached_inapp = models.BooleanField(default=True)
class Meta(TrackedModel.Meta):
verbose_name = "Notification Preference"
verbose_name_plural = "Notification Preferences"
def __str__(self):
return f"Notification preferences for {self.user.username}"
def should_send_notification(self, notification_type, channel):
"""
Check if a notification should be sent for a specific type and channel.
Args:
notification_type: The type of notification (from UserNotification.NotificationType)
channel: The delivery channel ('email', 'push', 'inapp')
Returns:
bool: True if notification should be sent, False otherwise
"""
field_name = f"{notification_type}_{channel}"
return getattr(self, field_name, False)
# Signal handlers for automatic notification preference creation
@receiver(post_save, sender=User)
def create_notification_preference(sender, instance, created, **kwargs):
"""Create notification preferences when a new user is created."""
if created:
NotificationPreference.objects.get_or_create(user=instance)
# Signal moved to signals.py to avoid duplication

View File

@@ -1,366 +0,0 @@
"""
User management services for ThrillWiki.
This module contains services for user account management including
user deletion while preserving submissions.
"""
from typing import Optional
from django.db import transaction
from django.utils import timezone
from django.conf import settings
from django.contrib.sites.models import Site
from django_forwardemail.services import EmailService
from .models import User, UserProfile, UserDeletionRequest
class UserDeletionService:
"""Service for handling user deletion while preserving submissions."""
DELETED_USER_USERNAME = "deleted_user"
DELETED_USER_EMAIL = "deleted@thrillwiki.com"
DELETED_DISPLAY_NAME = "Deleted User"
@classmethod
def get_or_create_deleted_user(cls) -> User:
"""Get or create the system deleted user placeholder."""
deleted_user, created = User.objects.get_or_create(
username=cls.DELETED_USER_USERNAME,
defaults={
"email": cls.DELETED_USER_EMAIL,
"is_active": False,
"is_staff": False,
"is_superuser": False,
"role": "USER",
"is_banned": True,
"ban_reason": "System placeholder for deleted users",
"ban_date": timezone.now(),
},
)
if created:
# Create profile for deleted user
UserProfile.objects.create(
user=deleted_user,
display_name=cls.DELETED_DISPLAY_NAME,
bio="This user account has been deleted.",
)
return deleted_user
@classmethod
@transaction.atomic
def delete_user_preserve_submissions(cls, user: User) -> dict:
"""
Delete a user while preserving all their submissions.
This method:
1. Transfers all user submissions to a system "deleted_user" placeholder
2. Deletes the user's profile and account data
3. Returns a summary of what was preserved
Args:
user: The user to delete
Returns:
dict: Summary of preserved submissions
"""
if user.username == cls.DELETED_USER_USERNAME:
raise ValueError("Cannot delete the system deleted user placeholder")
deleted_user = cls.get_or_create_deleted_user()
# Count submissions before transfer
submission_counts = {
"park_reviews": getattr(
user, "park_reviews", user.__class__.objects.none()
).count(),
"ride_reviews": getattr(
user, "ride_reviews", user.__class__.objects.none()
).count(),
"uploaded_park_photos": getattr(
user, "uploaded_park_photos", user.__class__.objects.none()
).count(),
"uploaded_ride_photos": getattr(
user, "uploaded_ride_photos", user.__class__.objects.none()
).count(),
"top_lists": getattr(
user, "top_lists", user.__class__.objects.none()
).count(),
"edit_submissions": getattr(
user, "edit_submissions", user.__class__.objects.none()
).count(),
"photo_submissions": getattr(
user, "photo_submissions", user.__class__.objects.none()
).count(),
"moderated_park_reviews": getattr(
user, "moderated_park_reviews", user.__class__.objects.none()
).count(),
"moderated_ride_reviews": getattr(
user, "moderated_ride_reviews", user.__class__.objects.none()
).count(),
"handled_submissions": getattr(
user, "handled_submissions", user.__class__.objects.none()
).count(),
"handled_photos": getattr(
user, "handled_photos", user.__class__.objects.none()
).count(),
}
# Transfer all submissions to deleted user
# Reviews
if hasattr(user, "park_reviews"):
getattr(user, "park_reviews").update(user=deleted_user)
if hasattr(user, "ride_reviews"):
getattr(user, "ride_reviews").update(user=deleted_user)
# Photos
if hasattr(user, "uploaded_park_photos"):
getattr(user, "uploaded_park_photos").update(uploaded_by=deleted_user)
if hasattr(user, "uploaded_ride_photos"):
getattr(user, "uploaded_ride_photos").update(uploaded_by=deleted_user)
# Top Lists
if hasattr(user, "top_lists"):
getattr(user, "top_lists").update(user=deleted_user)
# Moderation submissions
if hasattr(user, "edit_submissions"):
getattr(user, "edit_submissions").update(user=deleted_user)
if hasattr(user, "photo_submissions"):
getattr(user, "photo_submissions").update(user=deleted_user)
# Moderation actions - these can be set to NULL since they're not user content
if hasattr(user, "moderated_park_reviews"):
getattr(user, "moderated_park_reviews").update(moderated_by=None)
if hasattr(user, "moderated_ride_reviews"):
getattr(user, "moderated_ride_reviews").update(moderated_by=None)
if hasattr(user, "handled_submissions"):
getattr(user, "handled_submissions").update(handled_by=None)
if hasattr(user, "handled_photos"):
getattr(user, "handled_photos").update(handled_by=None)
# Store user info for the summary
user_info = {
"username": user.username,
"user_id": user.user_id,
"email": user.email,
"date_joined": user.date_joined,
}
# Delete the user (this will cascade delete the profile)
user.delete()
return {
"deleted_user": user_info,
"preserved_submissions": submission_counts,
"transferred_to": {
"username": deleted_user.username,
"user_id": deleted_user.user_id,
},
}
@classmethod
def can_delete_user(cls, user: User) -> tuple[bool, Optional[str]]:
"""
Check if a user can be safely deleted.
Args:
user: The user to check
Returns:
tuple: (can_delete: bool, reason: Optional[str])
"""
if user.username == cls.DELETED_USER_USERNAME:
return False, "Cannot delete the system deleted user placeholder"
if user.is_superuser:
return False, "Superuser accounts cannot be deleted for security reasons. Please contact system administrator or remove superuser privileges first."
# Check if user has critical admin role
if user.role == "ADMIN" and user.is_staff:
return False, "Admin accounts with staff privileges cannot be deleted. Please remove admin privileges first or contact system administrator."
# Add any other business rules here
return True, None
@classmethod
def request_user_deletion(cls, user: User) -> UserDeletionRequest:
"""
Create a user deletion request and send verification email.
Args:
user: The user requesting deletion
Returns:
UserDeletionRequest: The created deletion request
"""
# Check if user can be deleted
can_delete, reason = cls.can_delete_user(user)
if not can_delete:
raise ValueError(f"Cannot delete user: {reason}")
# Remove any existing deletion request for this user
UserDeletionRequest.objects.filter(user=user).delete()
# Create new deletion request
deletion_request = UserDeletionRequest.objects.create(user=user)
# Send verification email
cls.send_deletion_verification_email(deletion_request)
return deletion_request
@classmethod
def send_deletion_verification_email(cls, deletion_request: UserDeletionRequest):
"""
Send verification email for account deletion.
Args:
deletion_request: The deletion request to send email for
"""
user = deletion_request.user
# Get current site for email service
try:
site = Site.objects.get_current()
except Site.DoesNotExist:
# Fallback to default site
site = Site.objects.get_or_create(
id=1, defaults={"domain": "localhost:8000", "name": "localhost:8000"}
)[0]
# Prepare email context
context = {
"user": user,
"verification_code": deletion_request.verification_code,
"expires_at": deletion_request.expires_at,
"site_name": getattr(settings, "SITE_NAME", "ThrillWiki"),
"frontend_domain": getattr(
settings, "FRONTEND_DOMAIN", "http://localhost:3000"
),
}
# Render email content
subject = f"Confirm Account Deletion - {context['site_name']}"
# Create email message with 1-hour expiration notice
message = f"""
Hello {user.get_display_name()},
You have requested to delete your ThrillWiki account. To confirm this action, please use the following verification code:
Verification Code: {deletion_request.verification_code}
This code will expire in 1 hour on {deletion_request.expires_at.strftime('%B %d, %Y at %I:%M %p UTC')}.
IMPORTANT: This action cannot be undone. Your account will be permanently deleted, but all your reviews, photos, and other contributions will be preserved on the site.
If you did not request this deletion, please ignore this email and your account will remain active.
To complete the deletion, enter the verification code in the account deletion form on our website.
Best regards,
The ThrillWiki Team
""".strip()
# Send email using custom email service
try:
EmailService.send_email(
to=user.email,
subject=subject,
text=message,
site=site,
from_email="no-reply@thrillwiki.com",
)
# Update email sent timestamp
deletion_request.email_sent_at = timezone.now()
deletion_request.save(update_fields=["email_sent_at"])
except Exception as e:
# Log the error but don't fail the request creation
print(f"Failed to send deletion verification email to {user.email}: {e}")
@classmethod
@transaction.atomic
def verify_and_delete_user(cls, verification_code: str) -> dict:
"""
Verify deletion code and delete the user account.
Args:
verification_code: The verification code from the email
Returns:
dict: Summary of the deletion
Raises:
ValueError: If verification fails
"""
try:
deletion_request = UserDeletionRequest.objects.get(
verification_code=verification_code
)
except UserDeletionRequest.DoesNotExist:
raise ValueError("Invalid verification code")
# Check if request is still valid
if not deletion_request.is_valid():
if deletion_request.is_expired():
raise ValueError("Verification code has expired")
elif deletion_request.is_used:
raise ValueError("Verification code has already been used")
elif deletion_request.attempts >= deletion_request.max_attempts:
raise ValueError("Too many verification attempts")
else:
raise ValueError("Invalid verification code")
# Increment attempts
deletion_request.increment_attempts()
# Mark as used
deletion_request.mark_as_used()
# Delete the user
user = deletion_request.user
result = cls.delete_user_preserve_submissions(user)
# Add deletion request info to result
result["deletion_request"] = {
"verification_code": verification_code,
"created_at": deletion_request.created_at,
"verified_at": timezone.now(),
}
return result
@classmethod
def cancel_deletion_request(cls, user: User) -> bool:
"""
Cancel a pending deletion request.
Args:
user: The user whose deletion request to cancel
Returns:
bool: True if a request was cancelled, False if no request existed
"""
try:
deletion_request = getattr(user, "deletion_request", None)
if deletion_request:
deletion_request.delete()
return True
return False
except UserDeletionRequest.DoesNotExist:
return False
@classmethod
def cleanup_expired_deletion_requests(cls) -> int:
"""
Clean up expired deletion requests.
Returns:
int: Number of expired requests cleaned up
"""
return UserDeletionRequest.cleanup_expired()

View File

@@ -1,11 +0,0 @@
"""
Accounts Services Package
This package contains business logic services for account management,
including social provider management, user authentication, and profile services.
"""
from .social_provider_service import SocialProviderService
from .user_deletion_service import UserDeletionService
__all__ = ['SocialProviderService', 'UserDeletionService']

View File

@@ -1,351 +0,0 @@
"""
Notification service for creating and managing user notifications.
This service handles the creation, delivery, and management of notifications
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
from apps.accounts.models import User, UserNotification, NotificationPreference
from django_forwardemail.services import EmailService
logger = logging.getLogger(__name__)
class NotificationService:
"""Service for creating and managing user notifications."""
@staticmethod
def create_notification(
user: User,
notification_type: str,
title: str,
message: str,
related_object: Optional[Any] = None,
priority: str = UserNotification.Priority.NORMAL,
extra_data: Optional[Dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
) -> UserNotification:
"""
Create a new notification for a user.
Args:
user: The user to notify
notification_type: Type of notification (from UserNotification.NotificationType)
title: Notification title
message: Notification message
related_object: Optional related object (submission, review, etc.)
priority: Notification priority
extra_data: Additional data to store with notification
expires_at: When the notification expires
Returns:
UserNotification: The created notification
"""
# Get content type and object ID if related object provided
content_type = None
object_id = None
if related_object:
content_type = ContentType.objects.get_for_model(related_object)
object_id = related_object.pk
# Create the notification
notification = UserNotification.objects.create(
user=user,
notification_type=notification_type,
title=title,
message=message,
content_type=content_type,
object_id=object_id,
priority=priority,
extra_data=extra_data or {},
expires_at=expires_at,
)
# Send notification through appropriate channels
NotificationService._send_notification(notification)
return notification
@staticmethod
def create_submission_approved_notification(
user: User,
submission_object: Any,
submission_type: str,
additional_message: str = "",
) -> UserNotification:
"""
Create a notification for submission approval.
Args:
user: User who submitted the content
submission_object: The approved submission object
submission_type: Type of submission (e.g., "park photo", "ride review")
additional_message: Additional message from moderator
Returns:
UserNotification: The created notification
"""
title = f"Your {submission_type} has been approved!"
message = f"Great news! Your {submission_type} submission has been approved and is now live on ThrillWiki."
if additional_message:
message += f"\n\nModerator note: {additional_message}"
extra_data = {
"submission_type": submission_type,
"moderator_message": additional_message,
"approved_at": timezone.now().isoformat(),
}
return NotificationService.create_notification(
user=user,
notification_type=UserNotification.NotificationType.SUBMISSION_APPROVED,
title=title,
message=message,
related_object=submission_object,
priority=UserNotification.Priority.NORMAL,
extra_data=extra_data,
)
@staticmethod
def create_submission_rejected_notification(
user: User,
submission_object: Any,
submission_type: str,
rejection_reason: str,
additional_message: str = "",
) -> UserNotification:
"""
Create a notification for submission rejection.
Args:
user: User who submitted the content
submission_object: The rejected submission object
submission_type: Type of submission (e.g., "park photo", "ride review")
rejection_reason: Reason for rejection
additional_message: Additional message from moderator
Returns:
UserNotification: The created notification
"""
title = f"Your {submission_type} needs attention"
message = f"Your {submission_type} submission has been reviewed and needs some changes before it can be approved."
message += f"\n\nReason: {rejection_reason}"
if additional_message:
message += f"\n\nModerator note: {additional_message}"
message += "\n\nYou can edit and resubmit your content from your profile page."
extra_data = {
"submission_type": submission_type,
"rejection_reason": rejection_reason,
"moderator_message": additional_message,
"rejected_at": timezone.now().isoformat(),
}
return NotificationService.create_notification(
user=user,
notification_type=UserNotification.NotificationType.SUBMISSION_REJECTED,
title=title,
message=message,
related_object=submission_object,
priority=UserNotification.Priority.HIGH,
extra_data=extra_data,
)
@staticmethod
def create_submission_pending_notification(
user: User, submission_object: Any, submission_type: str
) -> UserNotification:
"""
Create a notification for submission pending review.
Args:
user: User who submitted the content
submission_object: The pending submission object
submission_type: Type of submission (e.g., "park photo", "ride review")
Returns:
UserNotification: The created notification
"""
title = f"Your {submission_type} is under review"
message = f"Thanks for your {submission_type} submission! It's now under review by our moderation team."
message += "\n\nWe'll notify you once it's been reviewed. This usually takes 1-2 business days."
extra_data = {
"submission_type": submission_type,
"submitted_at": timezone.now().isoformat(),
}
return NotificationService.create_notification(
user=user,
notification_type=UserNotification.NotificationType.SUBMISSION_PENDING,
title=title,
message=message,
related_object=submission_object,
priority=UserNotification.Priority.LOW,
extra_data=extra_data,
)
@staticmethod
def _send_notification(notification: UserNotification) -> None:
"""
Send notification through appropriate channels based on user preferences.
Args:
notification: The notification to send
"""
user = notification.user
# Get user's notification preferences
try:
preferences = user.notification_preference
except NotificationPreference.DoesNotExist:
# Create default preferences if they don't exist
preferences = NotificationPreference.objects.create(user=user)
# Send email notification if enabled
if preferences.should_send_notification(
notification.notification_type, "email"
):
NotificationService._send_email_notification(notification)
# Toast notifications are always created (the notification object itself)
# The frontend will display them as toast notifications based on preferences
@staticmethod
def _send_email_notification(notification: UserNotification) -> None:
"""
Send email notification to user using the custom ForwardEmail service.
Args:
notification: The notification to send via email
"""
try:
user = notification.user
# Prepare email context
context = {
"user": user,
"notification": notification,
"site_name": "ThrillWiki",
"site_url": getattr(settings, "SITE_URL", "https://thrillwiki.com"),
}
# Render email templates
subject = f"ThrillWiki: {notification.title}"
html_message = render_to_string("emails/notification.html", context)
plain_message = render_to_string("emails/notification.txt", context)
# Send email using custom ForwardEmail service
EmailService.send_email(
to=user.email,
subject=subject,
text=plain_message,
html=html_message,
)
# Mark as sent
notification.email_sent = True
notification.email_sent_at = timezone.now()
notification.save(update_fields=["email_sent", "email_sent_at"])
logger.info(
f"Email notification sent to {user.email} for notification {notification.id}"
)
except Exception as e:
logger.error(
f"Failed to send email notification {notification.id}: {str(e)}"
)
@staticmethod
def get_user_notifications(
user: User,
unread_only: bool = False,
notification_types: Optional[List[str]] = None,
limit: Optional[int] = None,
) -> List[UserNotification]:
"""
Get notifications for a user.
Args:
user: User to get notifications for
unread_only: Only return unread notifications
notification_types: Filter by notification types
limit: Limit number of results
Returns:
List[UserNotification]: List of notifications
"""
queryset = UserNotification.objects.filter(user=user)
if unread_only:
queryset = queryset.filter(is_read=False)
if notification_types:
queryset = queryset.filter(notification_type__in=notification_types)
# Exclude expired notifications
queryset = queryset.filter(
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=timezone.now())
)
if limit:
queryset = queryset[:limit]
return list(queryset)
@staticmethod
def mark_notifications_read(
user: User, notification_ids: Optional[List[int]] = None
) -> int:
"""
Mark notifications as read for a user.
Args:
user: User whose notifications to mark as read
notification_ids: Specific notification IDs to mark as read (if None, marks all)
Returns:
int: Number of notifications marked as read
"""
queryset = UserNotification.objects.filter(user=user, is_read=False)
if notification_ids:
queryset = queryset.filter(id__in=notification_ids)
return queryset.update(is_read=True, read_at=timezone.now())
@staticmethod
def cleanup_old_notifications(days: int = 90) -> int:
"""
Clean up old read notifications.
Args:
days: Number of days to keep read notifications
Returns:
int: Number of notifications deleted
"""
cutoff_date = timezone.now() - timedelta(days=days)
old_notifications = UserNotification.objects.filter(
is_read=True, read_at__lt=cutoff_date
)
count = old_notifications.count()
old_notifications.delete()
logger.info(f"Cleaned up {count} old notifications")
return count

View File

@@ -1,257 +0,0 @@
"""
Social Provider Management Service
This service handles the business logic for connecting and disconnecting
social authentication providers while ensuring users never lock themselves
out of their accounts.
"""
from typing import Dict, List, Tuple, TYPE_CHECKING
from django.contrib.auth import get_user_model
from allauth.socialaccount.models import SocialApp
from allauth.socialaccount.providers import registry
from django.contrib.sites.shortcuts import get_current_site
from django.http import HttpRequest
import logging
if TYPE_CHECKING:
from apps.accounts.models import User
else:
User = get_user_model()
logger = logging.getLogger(__name__)
class SocialProviderService:
"""Service for managing social provider connections."""
@staticmethod
def can_disconnect_provider(user: User, provider: str) -> Tuple[bool, str]:
"""
Check if a user can safely disconnect a social provider.
Args:
user: The user attempting to disconnect
provider: The provider to disconnect (e.g., 'google', 'discord')
Returns:
Tuple of (can_disconnect: bool, reason: str)
"""
try:
# Count remaining social accounts after disconnection
remaining_social_accounts = user.socialaccount_set.exclude(
provider=provider
).count()
# Check if user has email/password auth
has_password_auth = (
user.email and
user.has_usable_password() and
bool(user.password) # Not empty/unusable
)
# Allow disconnection only if alternative auth exists
can_disconnect = remaining_social_accounts > 0 or has_password_auth
if not can_disconnect:
if remaining_social_accounts == 0 and not has_password_auth:
return False, "Cannot disconnect your only authentication method. Please set up a password or connect another social provider first."
elif not has_password_auth:
return False, "Please set up email/password authentication before disconnecting this provider."
else:
return False, "Cannot disconnect this provider at this time."
return True, "Provider can be safely disconnected."
except Exception as e:
logger.error(
f"Error checking disconnect permission for user {user.id}, provider {provider}: {e}")
return False, "Unable to verify disconnection safety. Please try again."
@staticmethod
def get_connected_providers(user: "User") -> List[Dict]:
"""
Get all social providers connected to a user's account.
Args:
user: The user to check
Returns:
List of connected provider information
"""
try:
connected_providers = []
for social_account in user.socialaccount_set.all():
can_disconnect, reason = SocialProviderService.can_disconnect_provider(
user, social_account.provider
)
provider_info = {
'provider': social_account.provider,
'provider_name': social_account.get_provider().name,
'uid': social_account.uid,
'date_joined': social_account.date_joined,
'can_disconnect': can_disconnect,
'disconnect_reason': reason if not can_disconnect else None,
'extra_data': social_account.extra_data
}
connected_providers.append(provider_info)
return connected_providers
except Exception as e:
logger.error(f"Error getting connected providers for user {user.id}: {e}")
return []
@staticmethod
def get_available_providers(request: HttpRequest) -> List[Dict]:
"""
Get all available social providers for the current site.
Args:
request: The HTTP request
Returns:
List of available provider information
"""
try:
site = get_current_site(request)
available_providers = []
# Get all social apps configured for this site
social_apps = SocialApp.objects.filter(sites=site).order_by('provider')
for social_app in social_apps:
try:
provider = registry.by_id(social_app.provider)
provider_info = {
'id': social_app.provider,
'name': provider.name,
'auth_url': request.build_absolute_uri(
f'/accounts/{social_app.provider}/login/'
),
'connect_url': request.build_absolute_uri(
f'/api/v1/auth/social/connect/{social_app.provider}/'
)
}
available_providers.append(provider_info)
except Exception as e:
logger.warning(
f"Error processing provider {social_app.provider}: {e}")
continue
return available_providers
except Exception as e:
logger.error(f"Error getting available providers: {e}")
return []
@staticmethod
def disconnect_provider(user: "User", provider: str) -> Tuple[bool, str]:
"""
Disconnect a social provider from a user's account.
Args:
user: The user to disconnect from
provider: The provider to disconnect
Returns:
Tuple of (success: bool, message: str)
"""
try:
# First check if disconnection is allowed
can_disconnect, reason = SocialProviderService.can_disconnect_provider(
user, provider)
if not can_disconnect:
return False, reason
# Find and delete the social account
social_accounts = user.socialaccount_set.filter(provider=provider)
if not social_accounts.exists():
return False, f"No {provider} account found to disconnect."
# Delete all social accounts for this provider (in case of duplicates)
deleted_count = social_accounts.count()
social_accounts.delete()
logger.info(
f"User {user.id} disconnected {deleted_count} {provider} account(s)")
return True, f"{provider.title()} account disconnected successfully."
except Exception as e:
logger.error(f"Error disconnecting {provider} for user {user.id}: {e}")
return False, f"Failed to disconnect {provider} account. Please try again."
@staticmethod
def get_auth_status(user: "User") -> Dict:
"""
Get comprehensive authentication status for a user.
Args:
user: The user to check
Returns:
Dictionary with authentication status information
"""
try:
connected_providers = SocialProviderService.get_connected_providers(user)
has_password_auth = (
user.email and
user.has_usable_password() and
bool(user.password)
)
auth_methods_count = len(connected_providers) + \
(1 if has_password_auth else 0)
return {
'user_id': user.id,
'username': user.username,
'email': user.email,
'has_password_auth': has_password_auth,
'connected_providers': connected_providers,
'total_auth_methods': auth_methods_count,
'can_disconnect_any': auth_methods_count > 1,
'requires_password_setup': not has_password_auth and len(connected_providers) == 1
}
except Exception as e:
logger.error(f"Error getting auth status for user {user.id}: {e}")
return {
'error': 'Unable to retrieve authentication status'
}
@staticmethod
def validate_provider_exists(provider: str) -> Tuple[bool, str]:
"""
Validate that a social provider is configured and available.
Args:
provider: The provider ID to validate
Returns:
Tuple of (is_valid: bool, message: str)
"""
try:
# Check if provider is registered with allauth
if provider not in registry.provider_map:
return False, f"Provider '{provider}' is not supported."
# Check if provider has a social app configured
if not SocialApp.objects.filter(provider=provider).exists():
return False, f"Provider '{provider}' is not configured on this site."
return True, f"Provider '{provider}' is valid and available."
except Exception as e:
logger.error(f"Error validating provider {provider}: {e}")
return False, "Unable to validate provider."

View File

@@ -1,309 +0,0 @@
"""
User Deletion Service
This service handles user account deletion while preserving submissions
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 secrets
import string
from datetime import datetime
from apps.accounts.models import User
logger = logging.getLogger(__name__)
User = get_user_model()
class UserDeletionRequest:
"""Model for tracking user deletion requests."""
def __init__(self, user: User, verification_code: str, expires_at: datetime):
self.user = user
self.verification_code = verification_code
self.expires_at = expires_at
self.created_at = timezone.now()
class UserDeletionService:
"""Service for handling user account deletion with submission preservation."""
# In-memory storage for deletion requests (in production, use Redis or database)
_deletion_requests = {}
@staticmethod
def can_delete_user(user: User) -> Tuple[bool, Optional[str]]:
"""
Check if a user can be safely deleted.
Args:
user: User to check for deletion eligibility
Returns:
Tuple[bool, Optional[str]]: (can_delete, reason_if_not)
"""
# Prevent deletion of superusers
if user.is_superuser:
return False, "Cannot delete superuser accounts"
# Prevent deletion of staff/admin users
if user.is_staff:
return False, "Cannot delete staff accounts"
# Check for system users (if you have any special system accounts)
if hasattr(user, 'role') and user.role in ['ADMIN', 'MODERATOR']:
return False, "Cannot delete admin or moderator accounts"
return True, None
@staticmethod
def request_user_deletion(user: User) -> UserDeletionRequest:
"""
Create a deletion request for a user and send verification email.
Args:
user: User requesting deletion
Returns:
UserDeletionRequest: The deletion request object
Raises:
ValueError: If user cannot be deleted
"""
# Check if user can be deleted
can_delete, reason = UserDeletionService.can_delete_user(user)
if not can_delete:
raise ValueError(reason)
# Generate verification code
verification_code = ''.join(secrets.choice(
string.ascii_uppercase + string.digits) for _ in range(8))
# Set expiration (24 hours from now)
expires_at = timezone.now() + timezone.timedelta(hours=24)
# Create deletion request
deletion_request = UserDeletionRequest(user, verification_code, expires_at)
# Store request (in production, use Redis or database)
UserDeletionService._deletion_requests[verification_code] = deletion_request
# Send verification email
UserDeletionService._send_deletion_verification_email(
user, verification_code, expires_at)
return deletion_request
@staticmethod
def verify_and_delete_user(verification_code: str) -> Dict[str, Any]:
"""
Verify deletion code and delete user account.
Args:
verification_code: Verification code from email
Returns:
Dict[str, Any]: Deletion result information
Raises:
ValueError: If verification code is invalid or expired
"""
# Find deletion request
deletion_request = UserDeletionService._deletion_requests.get(verification_code)
if not deletion_request:
raise ValueError("Invalid verification code")
# Check if expired
if timezone.now() > deletion_request.expires_at:
# Clean up expired request
del UserDeletionService._deletion_requests[verification_code]
raise ValueError("Verification code has expired")
user = deletion_request.user
# Perform deletion
result = UserDeletionService.delete_user_preserve_submissions(user)
# Clean up deletion request
del UserDeletionService._deletion_requests[verification_code]
# Add verification info to result
result['deletion_request'] = {
'verification_code': verification_code,
'created_at': deletion_request.created_at,
'verified_at': timezone.now(),
}
return result
@staticmethod
def cancel_deletion_request(user: User) -> bool:
"""
Cancel a pending deletion request for a user.
Args:
user: User whose deletion request to cancel
Returns:
bool: True if request was found and cancelled, False if no request found
"""
# Find and remove any deletion requests for this user
to_remove = []
for code, request in UserDeletionService._deletion_requests.items():
if request.user.id == user.id:
to_remove.append(code)
for code in to_remove:
del UserDeletionService._deletion_requests[code]
return len(to_remove) > 0
@staticmethod
@transaction.atomic
def delete_user_preserve_submissions(user: User) -> Dict[str, Any]:
"""
Delete a user account while preserving all their submissions.
Args:
user: User to delete
Returns:
Dict[str, Any]: Information about the deletion and preserved submissions
"""
# Get or create the "deleted_user" placeholder
deleted_user_placeholder, created = User.objects.get_or_create(
username='deleted_user',
defaults={
'email': 'deleted@thrillwiki.com',
'first_name': 'Deleted',
'last_name': 'User',
'is_active': False,
}
)
# Count submissions before transfer
submission_counts = UserDeletionService._count_user_submissions(user)
# Transfer submissions to placeholder user
UserDeletionService._transfer_user_submissions(user, deleted_user_placeholder)
# Store user info before deletion
deleted_user_info = {
'username': user.username,
'user_id': getattr(user, 'user_id', user.id),
'email': user.email,
'date_joined': user.date_joined,
}
# Delete the user account
user.delete()
return {
'deleted_user': deleted_user_info,
'preserved_submissions': submission_counts,
'transferred_to': {
'username': deleted_user_placeholder.username,
'user_id': getattr(deleted_user_placeholder, 'user_id', deleted_user_placeholder.id),
}
}
@staticmethod
def _count_user_submissions(user: User) -> Dict[str, int]:
"""Count all submissions for a user."""
counts = {}
# Count different types of submissions
# Note: These are placeholder counts - adjust based on your actual models
counts['park_reviews'] = getattr(
user, 'park_reviews', user.__class__.objects.none()).count()
counts['ride_reviews'] = getattr(
user, 'ride_reviews', user.__class__.objects.none()).count()
counts['uploaded_park_photos'] = getattr(
user, 'uploaded_park_photos', user.__class__.objects.none()).count()
counts['uploaded_ride_photos'] = getattr(
user, 'uploaded_ride_photos', user.__class__.objects.none()).count()
counts['top_lists'] = getattr(
user, 'top_lists', user.__class__.objects.none()).count()
counts['edit_submissions'] = getattr(
user, 'edit_submissions', user.__class__.objects.none()).count()
counts['photo_submissions'] = getattr(
user, 'photo_submissions', user.__class__.objects.none()).count()
return counts
@staticmethod
def _transfer_user_submissions(user: User, placeholder_user: User) -> None:
"""Transfer all user submissions to placeholder user."""
# Transfer different types of submissions
# Note: Adjust these based on your actual model relationships
# Park reviews
if hasattr(user, 'park_reviews'):
user.park_reviews.all().update(user=placeholder_user)
# Ride reviews
if hasattr(user, 'ride_reviews'):
user.ride_reviews.all().update(user=placeholder_user)
# Uploaded photos
if hasattr(user, 'uploaded_park_photos'):
user.uploaded_park_photos.all().update(user=placeholder_user)
if hasattr(user, 'uploaded_ride_photos'):
user.uploaded_ride_photos.all().update(user=placeholder_user)
# Top lists
if hasattr(user, 'top_lists'):
user.top_lists.all().update(user=placeholder_user)
# Edit submissions
if hasattr(user, 'edit_submissions'):
user.edit_submissions.all().update(user=placeholder_user)
# Photo submissions
if hasattr(user, 'photo_submissions'):
user.photo_submissions.all().update(user=placeholder_user)
@staticmethod
def _send_deletion_verification_email(user: User, verification_code: str, expires_at: timezone.datetime) -> None:
"""Send verification email for account deletion."""
try:
context = {
'user': user,
'verification_code': verification_code,
'expires_at': expires_at,
'site_name': 'ThrillWiki',
'site_url': getattr(settings, 'SITE_URL', 'https://thrillwiki.com'),
}
subject = 'ThrillWiki: Confirm Account Deletion'
html_message = render_to_string(
'emails/account_deletion_verification.html', context)
plain_message = render_to_string(
'emails/account_deletion_verification.txt', context)
send_mail(
subject=subject,
message=plain_message,
html_message=html_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email],
fail_silently=False,
)
logger.info(f"Deletion verification email sent to {user.email}")
except Exception as e:
logger.error(
f"Failed to send deletion verification email to {user.email}: {str(e)}")
raise

View File

@@ -1,155 +0,0 @@
"""
Tests for user deletion while preserving submissions.
"""
from django.test import TestCase
from django.db import transaction
from apps.accounts.services import UserDeletionService
from apps.accounts.models import User, UserProfile
class UserDeletionServiceTest(TestCase):
"""Test cases for UserDeletionService."""
def setUp(self):
"""Set up test data."""
# Create test users
self.user = User.objects.create_user(
username="testuser", email="test@example.com", password="testpass123"
)
self.admin_user = User.objects.create_user(
username="admin",
email="admin@example.com",
password="adminpass123",
is_superuser=True,
)
# Create user profiles
UserProfile.objects.create(
user=self.user, display_name="Test User", bio="Test bio"
)
UserProfile.objects.create(
user=self.admin_user, display_name="Admin User", bio="Admin bio"
)
def test_get_or_create_deleted_user(self):
"""Test that deleted user placeholder is created correctly."""
deleted_user = UserDeletionService.get_or_create_deleted_user()
self.assertEqual(deleted_user.username, "deleted_user")
self.assertEqual(deleted_user.email, "deleted@thrillwiki.com")
self.assertFalse(deleted_user.is_active)
self.assertTrue(deleted_user.is_banned)
self.assertEqual(deleted_user.role, "USER")
# Check profile was created
self.assertTrue(hasattr(deleted_user, "profile"))
self.assertEqual(deleted_user.profile.display_name, "Deleted User")
def test_get_or_create_deleted_user_idempotent(self):
"""Test that calling get_or_create_deleted_user multiple times returns same user."""
deleted_user1 = UserDeletionService.get_or_create_deleted_user()
deleted_user2 = UserDeletionService.get_or_create_deleted_user()
self.assertEqual(deleted_user1.id, deleted_user2.id)
self.assertEqual(User.objects.filter(username="deleted_user").count(), 1)
def test_can_delete_user_normal_user(self):
"""Test that normal users can be deleted."""
can_delete, reason = UserDeletionService.can_delete_user(self.user)
self.assertTrue(can_delete)
self.assertIsNone(reason)
def test_can_delete_user_superuser(self):
"""Test that superusers cannot be deleted."""
can_delete, reason = UserDeletionService.can_delete_user(self.admin_user)
self.assertFalse(can_delete)
self.assertEqual(reason, "Cannot delete superuser accounts")
def test_can_delete_user_deleted_user_placeholder(self):
"""Test that deleted user placeholder cannot be deleted."""
deleted_user = UserDeletionService.get_or_create_deleted_user()
can_delete, reason = UserDeletionService.can_delete_user(deleted_user)
self.assertFalse(can_delete)
self.assertEqual(reason, "Cannot delete the system deleted user placeholder")
def test_delete_user_preserve_submissions_no_submissions(self):
"""Test deleting user with no submissions."""
user_id = self.user.user_id
username = self.user.username
result = UserDeletionService.delete_user_preserve_submissions(self.user)
# Check user was deleted
self.assertFalse(User.objects.filter(user_id=user_id).exists())
# Check result structure
self.assertIn("deleted_user", result)
self.assertIn("preserved_submissions", result)
self.assertIn("transferred_to", result)
self.assertEqual(result["deleted_user"]["username"], username)
self.assertEqual(result["deleted_user"]["user_id"], user_id)
# All submission counts should be 0
for count in result["preserved_submissions"].values():
self.assertEqual(count, 0)
def test_delete_user_cannot_delete_deleted_user_placeholder(self):
"""Test that attempting to delete the deleted user placeholder raises error."""
deleted_user = UserDeletionService.get_or_create_deleted_user()
with self.assertRaises(ValueError) as context:
UserDeletionService.delete_user_preserve_submissions(deleted_user)
self.assertIn(
"Cannot delete the system deleted user placeholder", str(context.exception)
)
def test_delete_user_with_submissions_transfers_correctly(self):
"""Test that user submissions are transferred to deleted user placeholder."""
# This test would require creating park/ride data which is complex
# For now, we'll test the basic functionality
# Create deleted user first to ensure it exists
UserDeletionService.get_or_create_deleted_user()
# Delete the test user
result = UserDeletionService.delete_user_preserve_submissions(self.user)
# Verify the deleted user placeholder still exists
self.assertTrue(User.objects.filter(username="deleted_user").exists())
# Verify result structure
self.assertIn("deleted_user", result)
self.assertIn("preserved_submissions", result)
self.assertIn("transferred_to", result)
self.assertEqual(result["transferred_to"]["username"], "deleted_user")
def test_delete_user_atomic_transaction(self):
"""Test that user deletion is atomic."""
# This test ensures that if something goes wrong during deletion,
# the transaction is rolled back
original_user_count = User.objects.count()
# Mock a failure during the deletion process
with self.assertRaises(Exception):
with transaction.atomic():
# Start the deletion process
UserDeletionService.get_or_create_deleted_user()
# Simulate an error
raise Exception("Simulated error during deletion")
# Verify user count hasn't changed
self.assertEqual(User.objects.count(), original_user_count)
# Verify our test user still exists
self.assertTrue(User.objects.filter(user_id=self.user.user_id).exists())

View File

@@ -1,12 +0,0 @@
"""
Core Django App
This app handles core system functionality including health checks,
system status, and other foundational features.
"""
# Import core choices to ensure they are registered with the global registry
from .choices import core_choices
# Ensure choices are registered on app startup
__all__ = ['core_choices']

View File

@@ -1,32 +0,0 @@
"""
Rich Choice Objects System
This module provides a comprehensive system for managing choice fields throughout
the ThrillWiki application. It replaces simple tuple-based choices with rich
dataclass objects that support metadata, descriptions, categories, and deprecation.
Key Components:
- RichChoice: Base dataclass for choice objects
- ChoiceRegistry: Centralized management of all choice definitions
- RichChoiceField: Django model field for rich choices
- RichChoiceSerializer: DRF serializer for API responses
"""
from .base import RichChoice, ChoiceCategory, ChoiceGroup
from .registry import ChoiceRegistry, register_choices
from .fields import RichChoiceField
from .serializers import RichChoiceSerializer, RichChoiceOptionSerializer
from .utils import validate_choice_value, get_choice_display
__all__ = [
'RichChoice',
'ChoiceCategory',
'ChoiceGroup',
'ChoiceRegistry',
'register_choices',
'RichChoiceField',
'RichChoiceSerializer',
'RichChoiceOptionSerializer',
'validate_choice_value',
'get_choice_display',
]

View File

@@ -1,154 +0,0 @@
"""
Base Rich Choice Objects
This module defines the core dataclass structures for rich choice objects.
"""
from dataclasses import dataclass, field
from typing import Dict, Any, Optional
from enum import Enum
class ChoiceCategory(Enum):
"""Categories for organizing choice types"""
STATUS = "status"
TYPE = "type"
CLASSIFICATION = "classification"
PREFERENCE = "preference"
PERMISSION = "permission"
PRIORITY = "priority"
ACTION = "action"
NOTIFICATION = "notification"
MODERATION = "moderation"
TECHNICAL = "technical"
BUSINESS = "business"
SECURITY = "security"
OTHER = "other"
@dataclass(frozen=True)
class RichChoice:
"""
Rich choice object with metadata support.
This replaces simple tuple choices with a comprehensive object that can
carry additional information like descriptions, colors, icons, and custom metadata.
Attributes:
value: The stored value (equivalent to first element of tuple choice)
label: Human-readable display name (equivalent to second element of tuple choice)
description: Detailed description of what this choice means
metadata: Dictionary of additional properties (colors, icons, etc.)
deprecated: Whether this choice is deprecated and should not be used for new entries
category: Category for organizing related choices
"""
value: str
label: str
description: str = ""
metadata: Dict[str, Any] = field(default_factory=dict)
deprecated: bool = False
category: ChoiceCategory = ChoiceCategory.OTHER
def __post_init__(self):
"""Validate the choice object after initialization"""
if not self.value:
raise ValueError("Choice value cannot be empty")
if not self.label:
raise ValueError("Choice label cannot be empty")
@property
def color(self) -> Optional[str]:
"""Get the color from metadata if available"""
return self.metadata.get('color')
@property
def icon(self) -> Optional[str]:
"""Get the icon from metadata if available"""
return self.metadata.get('icon')
@property
def css_class(self) -> Optional[str]:
"""Get the CSS class from metadata if available"""
return self.metadata.get('css_class')
@property
def sort_order(self) -> int:
"""Get the sort order from metadata, defaulting to 0"""
return self.metadata.get('sort_order', 0)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary representation for API serialization"""
return {
'value': self.value,
'label': self.label,
'description': self.description,
'metadata': self.metadata,
'deprecated': self.deprecated,
'category': self.category.value,
'color': self.color,
'icon': self.icon,
'css_class': self.css_class,
'sort_order': self.sort_order,
}
def __str__(self) -> str:
return self.label
def __repr__(self) -> str:
return f"RichChoice(value='{self.value}', label='{self.label}')"
@dataclass
class ChoiceGroup:
"""
A group of related choices with shared metadata.
This allows for organizing choices into logical groups with
common properties and behaviors.
"""
name: str
choices: list[RichChoice]
description: str = ""
metadata: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
"""Validate the choice group after initialization"""
if not self.name:
raise ValueError("Choice group name cannot be empty")
if not self.choices:
raise ValueError("Choice group must contain at least one choice")
# Validate that all choice values are unique within the group
values = [choice.value for choice in self.choices]
if len(values) != len(set(values)):
raise ValueError("All choice values within a group must be unique")
def get_choice(self, value: str) -> Optional[RichChoice]:
"""Get a choice by its value"""
for choice in self.choices:
if choice.value == value:
return choice
return None
def get_choices_by_category(self, category: ChoiceCategory) -> list[RichChoice]:
"""Get all choices in a specific category"""
return [choice for choice in self.choices if choice.category == category]
def get_active_choices(self) -> list[RichChoice]:
"""Get all non-deprecated choices"""
return [choice for choice in self.choices if not choice.deprecated]
def to_tuple_choices(self) -> list[tuple[str, str]]:
"""Convert to legacy tuple choices format"""
return [(choice.value, choice.label) for choice in self.choices]
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary representation for API serialization"""
return {
'name': self.name,
'description': self.description,
'metadata': self.metadata,
'choices': [choice.to_dict() for choice in self.choices]
}

View File

@@ -1,158 +0,0 @@
"""
Core System Rich Choice Objects
This module defines all choice objects for core system functionality,
including health checks, API statuses, and other system-level choices.
"""
from .base import RichChoice, ChoiceCategory
from .registry import register_choices
# Health Check Status Choices
HEALTH_STATUSES = [
RichChoice(
value="healthy",
label="Healthy",
description="System is operating normally with no issues detected",
metadata={
'color': 'green',
'icon': 'check-circle',
'css_class': 'bg-green-100 text-green-800',
'sort_order': 1,
'http_status': 200
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="unhealthy",
label="Unhealthy",
description="System has detected issues that may affect functionality",
metadata={
'color': 'red',
'icon': 'x-circle',
'css_class': 'bg-red-100 text-red-800',
'sort_order': 2,
'http_status': 503
},
category=ChoiceCategory.STATUS
),
]
# Simple Health Check Status Choices
SIMPLE_HEALTH_STATUSES = [
RichChoice(
value="ok",
label="OK",
description="Basic health check passed",
metadata={
'color': 'green',
'icon': 'check',
'css_class': 'bg-green-100 text-green-800',
'sort_order': 1,
'http_status': 200
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="error",
label="Error",
description="Basic health check failed",
metadata={
'color': 'red',
'icon': 'x',
'css_class': 'bg-red-100 text-red-800',
'sort_order': 2,
'http_status': 500
},
category=ChoiceCategory.STATUS
),
]
# Entity Type Choices for Search
ENTITY_TYPES = [
RichChoice(
value="park",
label="Park",
description="Theme parks and amusement parks",
metadata={
'color': 'green',
'icon': 'map-pin',
'css_class': 'bg-green-100 text-green-800',
'sort_order': 1,
'search_weight': 1.0
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="ride",
label="Ride",
description="Individual rides and attractions",
metadata={
'color': 'blue',
'icon': 'activity',
'css_class': 'bg-blue-100 text-blue-800',
'sort_order': 2,
'search_weight': 1.0
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="company",
label="Company",
description="Manufacturers, operators, and designers",
metadata={
'color': 'purple',
'icon': 'building',
'css_class': 'bg-purple-100 text-purple-800',
'sort_order': 3,
'search_weight': 0.8
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="user",
label="User",
description="User profiles and accounts",
metadata={
'color': 'orange',
'icon': 'user',
'css_class': 'bg-orange-100 text-orange-800',
'sort_order': 4,
'search_weight': 0.5
},
category=ChoiceCategory.CLASSIFICATION
),
]
def register_core_choices():
"""Register all core system choices with the global registry"""
register_choices(
name="health_statuses",
choices=HEALTH_STATUSES,
domain="core",
description="Health check status options",
metadata={'domain': 'core', 'type': 'health_status'}
)
register_choices(
name="simple_health_statuses",
choices=SIMPLE_HEALTH_STATUSES,
domain="core",
description="Simple health check status options",
metadata={'domain': 'core', 'type': 'simple_health_status'}
)
register_choices(
name="entity_types",
choices=ENTITY_TYPES,
domain="core",
description="Entity type classifications for search functionality",
metadata={'domain': 'core', 'type': 'entity_type'}
)
# Auto-register choices when module is imported
register_core_choices()

View File

@@ -1,198 +0,0 @@
"""
Django Model Fields for Rich Choices
This module provides Django model field implementations for rich choice objects.
"""
from typing import Any, Optional
from django.db import models
from django.core.exceptions import ValidationError
from django.forms import ChoiceField
from .base import RichChoice
from .registry import registry
class RichChoiceField(models.CharField):
"""
Django model field for rich choice objects.
This field stores the choice value as a CharField but provides
rich choice functionality through the registry system.
"""
def __init__(
self,
choice_group: str,
domain: str = "core",
max_length: int = 50,
allow_deprecated: bool = False,
**kwargs
):
"""
Initialize the RichChoiceField.
Args:
choice_group: Name of the choice group in the registry
domain: Domain namespace for the choice group
max_length: Maximum length for the stored value
allow_deprecated: Whether to allow deprecated choices
**kwargs: Additional arguments passed to CharField
"""
self.choice_group = choice_group
self.domain = domain
self.allow_deprecated = allow_deprecated
# Set choices from registry for Django admin and forms
if self.allow_deprecated:
choices_list = registry.get_choices(choice_group, domain)
else:
choices_list = registry.get_active_choices(choice_group, domain)
choices = [(choice.value, choice.label) for choice in choices_list]
kwargs['choices'] = choices
kwargs['max_length'] = max_length
super().__init__(**kwargs)
def validate(self, value: Any, model_instance: Any) -> None:
"""Validate the choice value"""
super().validate(value, model_instance)
if value is None or value == '':
return
# Check if choice exists in registry
choice = registry.get_choice(self.choice_group, value, self.domain)
if choice is None:
raise ValidationError(
f"'{value}' is not a valid choice for {self.choice_group}"
)
# Check if deprecated choices are allowed
if choice.deprecated and not self.allow_deprecated:
raise ValidationError(
f"'{value}' is deprecated and cannot be used for new entries"
)
def get_rich_choice(self, value: str) -> Optional[RichChoice]:
"""Get the RichChoice object for a value"""
return registry.get_choice(self.choice_group, value, self.domain)
def get_choice_display(self, value: str) -> str:
"""Get the display label for a choice value"""
return registry.get_choice_display(self.choice_group, value, self.domain)
def contribute_to_class(self, cls: Any, name: str, private_only: bool = False, **kwargs: Any) -> None:
"""Add helper methods to the model class (signature compatible with Django Field)"""
super().contribute_to_class(cls, name, private_only=private_only, **kwargs)
# Add get_FOO_rich_choice method
def get_rich_choice_method(instance):
value = getattr(instance, name)
return self.get_rich_choice(value) if value else None
setattr(cls, f'get_{name}_rich_choice', get_rich_choice_method)
# Add get_FOO_display method (Django provides this, but we enhance it)
def get_display_method(instance):
value = getattr(instance, name)
return self.get_choice_display(value) if value else ''
setattr(cls, f'get_{name}_display', get_display_method)
def deconstruct(self):
"""Support for Django migrations"""
name, path, args, kwargs = super().deconstruct()
kwargs['choice_group'] = self.choice_group
kwargs['domain'] = self.domain
kwargs['allow_deprecated'] = self.allow_deprecated
return name, path, args, kwargs
class RichChoiceFormField(ChoiceField):
"""
Form field for rich choices with enhanced functionality.
"""
def __init__(
self,
choice_group: str,
domain: str = "core",
allow_deprecated: bool = False,
show_descriptions: bool = False,
**kwargs
):
"""
Initialize the form field.
Args:
choice_group: Name of the choice group in the registry
domain: Domain namespace for the choice group
allow_deprecated: Whether to allow deprecated choices
show_descriptions: Whether to show descriptions in choice labels
**kwargs: Additional arguments passed to ChoiceField
"""
self.choice_group = choice_group
self.domain = domain
self.allow_deprecated = allow_deprecated
self.show_descriptions = show_descriptions
# Get choices from registry
if allow_deprecated:
choices_list = registry.get_choices(choice_group, domain)
else:
choices_list = registry.get_active_choices(choice_group, domain)
# Format choices for display
choices = []
for choice in choices_list:
label = choice.label
if show_descriptions and choice.description:
label = f"{choice.label} - {choice.description}"
choices.append((choice.value, label))
kwargs['choices'] = choices
super().__init__(**kwargs)
def validate(self, value: Any) -> None:
"""Validate the choice value"""
super().validate(value)
if value is None or value == '':
return
# Check if choice exists in registry
choice = registry.get_choice(self.choice_group, value, self.domain)
if choice is None:
raise ValidationError(
f"'{value}' is not a valid choice for {self.choice_group}"
)
# Check if deprecated choices are allowed
if choice.deprecated and not self.allow_deprecated:
raise ValidationError(
f"'{value}' is deprecated and cannot be used"
)
def create_rich_choice_field(
choice_group: str,
domain: str = "core",
max_length: int = 50,
allow_deprecated: bool = False,
**kwargs
) -> RichChoiceField:
"""
Factory function to create a RichChoiceField.
This is useful for creating fields with consistent settings
across multiple models.
"""
return RichChoiceField(
choice_group=choice_group,
domain=domain,
max_length=max_length,
allow_deprecated=allow_deprecated,
**kwargs
)

View File

@@ -1,197 +0,0 @@
"""
Choice Registry
Centralized registry for managing all choice definitions across the application.
"""
from typing import Dict, List, Optional, Any
from django.core.exceptions import ImproperlyConfigured
from .base import RichChoice, ChoiceGroup
class ChoiceRegistry:
"""
Centralized registry for managing all choice definitions.
This provides a single source of truth for all choice objects
throughout the application, with support for namespacing by domain.
"""
def __init__(self):
self._choices: Dict[str, ChoiceGroup] = {}
self._domains: Dict[str, List[str]] = {}
def register(
self,
name: str,
choices: List[RichChoice],
domain: str = "core",
description: str = "",
metadata: Optional[Dict[str, Any]] = None
) -> ChoiceGroup:
"""
Register a group of choices.
Args:
name: Unique name for the choice group
choices: List of RichChoice objects
domain: Domain namespace (e.g., 'rides', 'parks', 'accounts')
description: Description of the choice group
metadata: Additional metadata for the group
Returns:
The registered ChoiceGroup
Raises:
ImproperlyConfigured: If name is already registered with different choices
"""
full_name = f"{domain}.{name}"
if full_name in self._choices:
# Check if the existing registration is identical
existing_group = self._choices[full_name]
existing_values = [choice.value for choice in existing_group.choices]
new_values = [choice.value for choice in choices]
if existing_values == new_values:
# Same choices, return existing group (allow duplicate registration)
return existing_group
else:
# Different choices, this is an error
raise ImproperlyConfigured(
f"Choice group '{full_name}' is already registered with different choices. "
f"Existing: {existing_values}, New: {new_values}"
)
choice_group = ChoiceGroup(
name=full_name,
choices=choices,
description=description,
metadata=metadata or {}
)
self._choices[full_name] = choice_group
# Track domain
if domain not in self._domains:
self._domains[domain] = []
self._domains[domain].append(name)
return choice_group
def get(self, name: str, domain: str = "core") -> Optional[ChoiceGroup]:
"""Get a choice group by name and domain"""
full_name = f"{domain}.{name}"
return self._choices.get(full_name)
def get_choice(self, group_name: str, value: str, domain: str = "core") -> Optional[RichChoice]:
"""Get a specific choice by group name, value, and domain"""
choice_group = self.get(group_name, domain)
if choice_group:
return choice_group.get_choice(value)
return None
def get_choices(self, name: str, domain: str = "core") -> List[RichChoice]:
"""Get all choices in a group"""
choice_group = self.get(name, domain)
return choice_group.choices if choice_group else []
def get_active_choices(self, name: str, domain: str = "core") -> List[RichChoice]:
"""Get all non-deprecated choices in a group"""
choice_group = self.get(name, domain)
return choice_group.get_active_choices() if choice_group else []
def get_domains(self) -> List[str]:
"""Get all registered domains"""
return list(self._domains.keys())
def get_domain_choices(self, domain: str) -> Dict[str, ChoiceGroup]:
"""Get all choice groups for a specific domain"""
if domain not in self._domains:
return {}
return {
name: self._choices[f"{domain}.{name}"]
for name in self._domains[domain]
}
def list_all(self) -> Dict[str, ChoiceGroup]:
"""Get all registered choice groups"""
return self._choices.copy()
def validate_choice(self, group_name: str, value: str, domain: str = "core") -> bool:
"""Validate that a choice value exists in a group"""
choice = self.get_choice(group_name, value, domain)
return choice is not None and not choice.deprecated
def get_choice_display(self, group_name: str, value: str, domain: str = "core") -> str:
"""Get the display label for a choice value"""
choice = self.get_choice(group_name, value, domain)
if choice:
return choice.label
else:
raise ValueError(f"Choice value '{value}' not found in group '{group_name}' for domain '{domain}'")
def clear_domain(self, domain: str) -> None:
"""Clear all choices for a specific domain (useful for testing)"""
if domain in self._domains:
for name in self._domains[domain]:
full_name = f"{domain}.{name}"
if full_name in self._choices:
del self._choices[full_name]
del self._domains[domain]
def clear_all(self) -> None:
"""Clear all registered choices (useful for testing)"""
self._choices.clear()
self._domains.clear()
# Global registry instance
registry = ChoiceRegistry()
def register_choices(
name: str,
choices: List[RichChoice],
domain: str = "core",
description: str = "",
metadata: Optional[Dict[str, Any]] = None
) -> ChoiceGroup:
"""
Convenience function to register choices with the global registry.
Args:
name: Unique name for the choice group
choices: List of RichChoice objects
domain: Domain namespace
description: Description of the choice group
metadata: Additional metadata for the group
Returns:
The registered ChoiceGroup
"""
return registry.register(name, choices, domain, description, metadata)
def get_choices(name: str, domain: str = "core") -> List[RichChoice]:
"""Get choices from the global registry"""
return registry.get_choices(name, domain)
def get_choice(group_name: str, value: str, domain: str = "core") -> Optional[RichChoice]:
"""Get a specific choice from the global registry"""
return registry.get_choice(group_name, value, domain)
def validate_choice(group_name: str, value: str, domain: str = "core") -> bool:
"""Validate a choice value using the global registry"""
return registry.validate_choice(group_name, value, domain)
def get_choice_display(group_name: str, value: str, domain: str = "core") -> str:
"""Get choice display label using the global registry"""
return registry.get_choice_display(group_name, value, domain)

View File

@@ -1,275 +0,0 @@
"""
DRF Serializers for Rich Choices
This module provides Django REST Framework serializer implementations
for rich choice objects.
"""
from typing import Any, Dict, List
from rest_framework import serializers
from .base import RichChoice, ChoiceGroup
from .registry import registry
class RichChoiceSerializer(serializers.Serializer):
"""
Serializer for individual RichChoice objects.
This provides a consistent API representation for choice objects
with all their metadata.
"""
value = serializers.CharField()
label = serializers.CharField()
description = serializers.CharField()
metadata = serializers.DictField()
deprecated = serializers.BooleanField()
category = serializers.CharField()
color = serializers.CharField(allow_null=True)
icon = serializers.CharField(allow_null=True)
css_class = serializers.CharField(allow_null=True)
sort_order = serializers.IntegerField()
def to_representation(self, instance: RichChoice) -> Dict[str, Any]:
"""Convert RichChoice to dictionary representation"""
return instance.to_dict()
class RichChoiceOptionSerializer(serializers.Serializer):
"""
Serializer for choice options in filter endpoints.
This replaces the legacy FilterOptionSerializer with rich choice support.
"""
value = serializers.CharField()
label = serializers.CharField()
description = serializers.CharField(allow_blank=True)
count = serializers.IntegerField(required=False, allow_null=True)
selected = serializers.BooleanField(default=False)
deprecated = serializers.BooleanField(default=False)
color = serializers.CharField(allow_null=True, required=False)
icon = serializers.CharField(allow_null=True, required=False)
css_class = serializers.CharField(allow_null=True, required=False)
metadata = serializers.DictField(required=False)
def to_representation(self, instance) -> Dict[str, Any]:
"""Convert choice option to dictionary representation"""
if isinstance(instance, RichChoice):
# Convert RichChoice to option format
return {
'value': instance.value,
'label': instance.label,
'description': instance.description,
'count': None,
'selected': False,
'deprecated': instance.deprecated,
'color': instance.color,
'icon': instance.icon,
'css_class': instance.css_class,
'metadata': instance.metadata,
}
elif isinstance(instance, dict):
# Handle dictionary input (for backwards compatibility)
return {
'value': instance.get('value', ''),
'label': instance.get('label', ''),
'description': instance.get('description', ''),
'count': instance.get('count'),
'selected': instance.get('selected', False),
'deprecated': instance.get('deprecated', False),
'color': instance.get('color'),
'icon': instance.get('icon'),
'css_class': instance.get('css_class'),
'metadata': instance.get('metadata', {}),
}
else:
return super().to_representation(instance)
class ChoiceGroupSerializer(serializers.Serializer):
"""
Serializer for ChoiceGroup objects.
This provides API representation for entire choice groups
with all their choices and metadata.
"""
name = serializers.CharField()
description = serializers.CharField()
metadata = serializers.DictField()
choices = RichChoiceSerializer(many=True)
def to_representation(self, instance: ChoiceGroup) -> Dict[str, Any]:
"""Convert ChoiceGroup to dictionary representation"""
return instance.to_dict()
class RichChoiceFieldSerializer(serializers.CharField):
"""
Serializer field for rich choice values.
This field serializes the choice value but can optionally
include rich choice metadata in the response.
"""
def __init__(
self,
choice_group: str,
domain: str = "core",
include_metadata: bool = False,
**kwargs
):
"""
Initialize the serializer field.
Args:
choice_group: Name of the choice group in the registry
domain: Domain namespace for the choice group
include_metadata: Whether to include rich choice metadata
**kwargs: Additional arguments passed to CharField
"""
self.choice_group = choice_group
self.domain = domain
self.include_metadata = include_metadata
super().__init__(**kwargs)
def to_representation(self, value: str) -> Any:
"""Convert choice value to representation"""
if not value:
return value
if self.include_metadata:
# Return rich choice object
choice = registry.get_choice(self.choice_group, value, self.domain)
if choice:
return RichChoiceSerializer(choice).data
else:
# Fallback for unknown values
return {
'value': value,
'label': value,
'description': '',
'metadata': {},
'deprecated': False,
'category': 'other',
'color': None,
'icon': None,
'css_class': None,
'sort_order': 0,
}
else:
# Return just the value
return value
def to_internal_value(self, data: Any) -> str:
"""Convert input data to choice value"""
if isinstance(data, dict) and 'value' in data:
# Handle rich choice object input
return data['value']
else:
# Handle string input
return super().to_internal_value(data)
def create_choice_options_serializer(
choice_group: str,
domain: str = "core",
include_counts: bool = False,
queryset=None,
count_field: str = 'id'
) -> List[Dict[str, Any]]:
"""
Create choice options for filter endpoints.
This function generates choice options with optional counts
for use in filter metadata endpoints.
Args:
choice_group: Name of the choice group in the registry
domain: Domain namespace for the choice group
include_counts: Whether to include counts for each option
queryset: QuerySet to count against (required if include_counts=True)
count_field: Field to filter on for counting (default: 'id')
Returns:
List of choice option dictionaries
"""
choices = registry.get_active_choices(choice_group, domain)
options = []
for choice in choices:
option_data = {
'value': choice.value,
'label': choice.label,
'description': choice.description,
'selected': False,
'deprecated': choice.deprecated,
'color': choice.color,
'icon': choice.icon,
'css_class': choice.css_class,
'metadata': choice.metadata,
}
if include_counts and queryset is not None:
# Count items for this choice
try:
count = queryset.filter(**{count_field: choice.value}).count()
option_data['count'] = count
except Exception:
# If counting fails, set count to None
option_data['count'] = None
else:
option_data['count'] = None
options.append(option_data)
# Sort by sort_order, then by label
options.sort(key=lambda x: (
(lambda c: c.sort_order if (c is not None and hasattr(c, 'sort_order')) else 0)(
registry.get_choice(choice_group, x['value'], domain)
),
x['label']
))
return options
def serialize_choice_value(
value: str,
choice_group: str,
domain: str = "core",
include_metadata: bool = False
) -> Any:
"""
Serialize a single choice value.
Args:
value: The choice value to serialize
choice_group: Name of the choice group in the registry
domain: Domain namespace for the choice group
include_metadata: Whether to include rich choice metadata
Returns:
Serialized choice value (string or rich object)
"""
if not value:
return value
if include_metadata:
choice = registry.get_choice(choice_group, value, domain)
if choice:
return RichChoiceSerializer(choice).data
else:
# Fallback for unknown values
return {
'value': value,
'label': value,
'description': '',
'metadata': {},
'deprecated': False,
'category': 'other',
'color': None,
'icon': None,
'css_class': None,
'sort_order': 0,
}
else:
return value

View File

@@ -1,318 +0,0 @@
"""
Utility Functions for Rich Choices
This module provides utility functions for working with rich choice objects.
"""
from typing import Any, Dict, List, Optional, Tuple
from .base import RichChoice, ChoiceCategory
from .registry import registry
def validate_choice_value(
value: str,
choice_group: str,
domain: str = "core",
allow_deprecated: bool = False
) -> bool:
"""
Validate that a choice value is valid for a given choice group.
Args:
value: The choice value to validate
choice_group: Name of the choice group in the registry
domain: Domain namespace for the choice group
allow_deprecated: Whether to allow deprecated choices
Returns:
True if valid, False otherwise
"""
if not value:
return True # Allow empty values (handled by field's null/blank settings)
choice = registry.get_choice(choice_group, value, domain)
if choice is None:
return False
if choice.deprecated and not allow_deprecated:
return False
return True
def get_choice_display(
value: str,
choice_group: str,
domain: str = "core"
) -> str:
"""
Get the display label for a choice value.
Args:
value: The choice value
choice_group: Name of the choice group in the registry
domain: Domain namespace for the choice group
Returns:
Display label for the choice
Raises:
ValueError: If the choice value is not found in the registry
"""
if not value:
return ""
choice = registry.get_choice(choice_group, value, domain)
if choice:
return choice.label
else:
raise ValueError(f"Choice value '{value}' not found in group '{choice_group}' for domain '{domain}'")
def create_status_choices(
statuses: Dict[str, Dict[str, Any]],
category: ChoiceCategory = ChoiceCategory.STATUS
) -> List[RichChoice]:
"""
Create status choices with consistent color coding.
Args:
statuses: Dictionary mapping status value to config dict
category: Choice category (defaults to STATUS)
Returns:
List of RichChoice objects for statuses
"""
choices = []
for value, config in statuses.items():
metadata = config.get('metadata', {})
# Add default status colors if not specified
if 'color' not in metadata:
if 'operating' in value.lower() or 'active' in value.lower():
metadata['color'] = 'green'
elif 'closed' in value.lower() or 'inactive' in value.lower():
metadata['color'] = 'red'
elif 'temp' in value.lower() or 'pending' in value.lower():
metadata['color'] = 'yellow'
elif 'construction' in value.lower():
metadata['color'] = 'blue'
else:
metadata['color'] = 'gray'
choice = RichChoice(
value=value,
label=config['label'],
description=config.get('description', ''),
metadata=metadata,
deprecated=config.get('deprecated', False),
category=category
)
choices.append(choice)
return choices
def create_type_choices(
types: Dict[str, Dict[str, Any]],
category: ChoiceCategory = ChoiceCategory.TYPE
) -> List[RichChoice]:
"""
Create type/classification choices.
Args:
types: Dictionary mapping type value to config dict
category: Choice category (defaults to TYPE)
Returns:
List of RichChoice objects for types
"""
choices = []
for value, config in types.items():
choice = RichChoice(
value=value,
label=config['label'],
description=config.get('description', ''),
metadata=config.get('metadata', {}),
deprecated=config.get('deprecated', False),
category=category
)
choices.append(choice)
return choices
def merge_choice_metadata(
base_metadata: Dict[str, Any],
override_metadata: Dict[str, Any]
) -> Dict[str, Any]:
"""
Merge choice metadata dictionaries.
Args:
base_metadata: Base metadata dictionary
override_metadata: Override metadata dictionary
Returns:
Merged metadata dictionary
"""
merged = base_metadata.copy()
merged.update(override_metadata)
return merged
def filter_choices_by_category(
choices: List[RichChoice],
category: ChoiceCategory
) -> List[RichChoice]:
"""
Filter choices by category.
Args:
choices: List of RichChoice objects
category: Category to filter by
Returns:
Filtered list of choices
"""
return [choice for choice in choices if choice.category == category]
def sort_choices(
choices: List[RichChoice],
sort_by: str = "sort_order"
) -> List[RichChoice]:
"""
Sort choices by specified criteria.
Args:
choices: List of RichChoice objects
sort_by: Sort criteria ("sort_order", "label", "value")
Returns:
Sorted list of choices
"""
if sort_by == "sort_order":
return sorted(choices, key=lambda x: (x.sort_order, x.label))
elif sort_by == "label":
return sorted(choices, key=lambda x: x.label)
elif sort_by == "value":
return sorted(choices, key=lambda x: x.value)
else:
return choices
def get_choice_colors(
choice_group: str,
domain: str = "core"
) -> Dict[str, str]:
"""
Get a mapping of choice values to their colors.
Args:
choice_group: Name of the choice group in the registry
domain: Domain namespace for the choice group
Returns:
Dictionary mapping choice values to colors
"""
choices = registry.get_choices(choice_group, domain)
return {
choice.value: choice.color
for choice in choices
if choice.color
}
def validate_choice_group_data(
name: str,
choices: List[RichChoice],
domain: str = "core"
) -> List[str]:
"""
Validate choice group data and return list of errors.
Args:
name: Choice group name
choices: List of RichChoice objects
domain: Domain namespace
Returns:
List of validation error messages
"""
errors = []
if not name:
errors.append("Choice group name cannot be empty")
if not choices:
errors.append("Choice group must contain at least one choice")
return errors
# Check for duplicate values
values = [choice.value for choice in choices]
if len(values) != len(set(values)):
duplicates = [v for v in values if values.count(v) > 1]
errors.append(f"Duplicate choice values found: {', '.join(set(duplicates))}")
# Validate individual choices
for i, choice in enumerate(choices):
try:
# This will trigger __post_init__ validation
RichChoice(
value=choice.value,
label=choice.label,
description=choice.description,
metadata=choice.metadata,
deprecated=choice.deprecated,
category=choice.category
)
except ValueError as e:
errors.append(f"Choice {i}: {str(e)}")
return errors
def create_choice_from_config(config: Dict[str, Any]) -> RichChoice:
"""
Create a RichChoice from a configuration dictionary.
Args:
config: Configuration dictionary with choice data
Returns:
RichChoice object
"""
return RichChoice(
value=config['value'],
label=config['label'],
description=config.get('description', ''),
metadata=config.get('metadata', {}),
deprecated=config.get('deprecated', False),
category=ChoiceCategory(config.get('category', 'other'))
)
def export_choices_to_dict(
choice_group: str,
domain: str = "core"
) -> Dict[str, Any]:
"""
Export a choice group to a dictionary format.
Args:
choice_group: Name of the choice group in the registry
domain: Domain namespace for the choice group
Returns:
Dictionary representation of the choice group
"""
group = registry.get(choice_group, domain)
if not group:
return {}
return group.to_dict()

View File

@@ -1,217 +0,0 @@
"""
Django management command to calculate new content.
This replaces the Celery task for calculating new content.
Run with: uv run manage.py calculate_new_content
"""
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from django.core.cache import cache
from django.db.models import Q
from apps.parks.models import Park
from apps.rides.models import Ride
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Calculate new content and cache results"
def add_arguments(self, parser):
parser.add_argument(
"--content-type",
type=str,
default="all",
choices=["all", "parks", "rides"],
help="Type of content to calculate (default: all)",
)
parser.add_argument(
"--days-back",
type=int,
default=30,
help="Number of days to look back for new content (default: 30)",
)
parser.add_argument(
"--limit",
type=int,
default=50,
help="Maximum number of results to calculate (default: 50)",
)
parser.add_argument(
"--verbose", action="store_true", help="Enable verbose output"
)
def handle(self, *args, **options):
content_type = options["content_type"]
days_back = options["days_back"]
limit = options["limit"]
verbose = options["verbose"]
if verbose:
self.stdout.write(f"Starting new content calculation for {content_type}")
try:
cutoff_date = timezone.now() - timedelta(days=days_back)
new_items = []
if content_type in ["all", "parks"]:
parks = self._get_new_parks(
cutoff_date, limit if content_type == "parks" else limit * 2
)
new_items.extend(parks)
if verbose:
self.stdout.write(f"Found {len(parks)} new parks")
if content_type in ["all", "rides"]:
rides = self._get_new_rides(
cutoff_date, limit if content_type == "rides" else limit * 2
)
new_items.extend(rides)
if verbose:
self.stdout.write(f"Found {len(rides)} new rides")
# Sort by date added (most recent first) and apply limit
new_items.sort(key=lambda x: x.get("date_added", ""), reverse=True)
new_items = new_items[:limit]
# Format results for API consumption
formatted_results = self._format_new_content_results(new_items)
# Cache results
cache_key = f"new_content:calculated:{content_type}:{days_back}:{limit}"
cache.set(cache_key, formatted_results, 1800) # Cache for 30 minutes
self.stdout.write(
self.style.SUCCESS(
f"Successfully calculated {len(formatted_results)} new items for {content_type}"
)
)
if verbose:
for item in formatted_results[:5]: # Show first 5 items
self.stdout.write(
f" {item['name']} ({item['park']}) - opened: {item['date_opened']}"
)
except Exception as e:
logger.error(f"Error calculating new content: {e}", exc_info=True)
raise CommandError(f"Failed to calculate new content: {e}")
def _get_new_parks(self, cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]:
"""Get recently added parks using real data."""
new_parks = (
Park.objects.filter(
Q(created_at__gte=cutoff_date)
| Q(opening_date__gte=cutoff_date.date()),
status="OPERATING",
)
.select_related("location", "operator")
.order_by("-created_at", "-opening_date")[:limit]
)
results = []
for park in new_parks:
date_added = park.opening_date or park.created_at
if date_added:
if isinstance(date_added, datetime):
date_added = date_added.date()
opening_date = getattr(park, "opening_date", None)
if opening_date and isinstance(opening_date, datetime):
opening_date = opening_date.date()
results.append(
{
"content_object": park,
"content_type": "park",
"id": park.pk,
"name": park.name,
"slug": park.slug,
"park": park.name, # For parks, park field is the park name itself
"category": "park",
"date_added": date_added.isoformat() if date_added else "",
"date_opened": opening_date.isoformat() if opening_date else "",
"url": park.url,
}
)
return results
def _get_new_rides(self, cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]:
"""Get recently added rides using real data."""
new_rides = (
Ride.objects.filter(
Q(created_at__gte=cutoff_date)
| Q(opening_date__gte=cutoff_date.date()),
status="OPERATING",
)
.select_related("park", "park__location")
.order_by("-created_at", "-opening_date")[:limit]
)
results = []
for ride in new_rides:
date_added = getattr(ride, "opening_date", None) or getattr(
ride, "created_at", None
)
if date_added:
if isinstance(date_added, datetime):
date_added = date_added.date()
opening_date = getattr(ride, "opening_date", None)
if opening_date and isinstance(opening_date, datetime):
opening_date = opening_date.date()
results.append(
{
"content_object": ride,
"content_type": "ride",
"id": ride.pk,
"name": ride.name,
"slug": ride.slug,
"park": ride.park.name if ride.park else "",
"category": "ride",
"date_added": date_added.isoformat() if date_added else "",
"date_opened": opening_date.isoformat() if opening_date else "",
"url": ride.url,
"park_url": ride.park.url if ride.park else "",
}
)
return results
def _format_new_content_results(
self, new_items: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""Format new content results for frontend consumption."""
formatted_results = []
for item in new_items:
try:
# Format exactly as frontend expects
formatted_item = {
"id": item["id"],
"name": item["name"],
"park": item["park"],
"category": item["category"],
"date_added": item["date_added"],
"date_opened": item["date_opened"],
"slug": item["slug"],
"url": item["url"],
}
# Add park_url for rides
if item.get("park_url"):
formatted_item["park_url"] = item["park_url"]
formatted_results.append(formatted_item)
except Exception as e:
logger.warning(f"Error formatting new content item: {e}")
return formatted_results

View File

@@ -1,391 +0,0 @@
"""
Django management command to calculate trending content.
This replaces the Celery task for calculating trending content.
Run with: uv run manage.py calculate_trending
"""
import logging
from typing import Dict, List, Any
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from django.core.cache import cache
from django.contrib.contenttypes.models import ContentType
from apps.core.analytics import PageView
from apps.parks.models import Park
from apps.rides.models import Ride
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Calculate trending content and cache results"
def add_arguments(self, parser):
parser.add_argument(
"--content-type",
type=str,
default="all",
choices=["all", "parks", "rides"],
help="Type of content to calculate (default: all)",
)
parser.add_argument(
"--limit",
type=int,
default=50,
help="Maximum number of results to calculate (default: 50)",
)
parser.add_argument(
"--verbose", action="store_true", help="Enable verbose output"
)
def handle(self, *args, **options):
content_type = options["content_type"]
limit = options["limit"]
verbose = options["verbose"]
if verbose:
self.stdout.write(f"Starting trending calculation for {content_type}")
try:
# Time windows for calculations
current_period_hours = 168 # 7 days
# 14 days (for previous 7-day window comparison)
previous_period_hours = 336
trending_items = []
if content_type in ["all", "parks"]:
park_items = self._calculate_trending_parks(
current_period_hours,
previous_period_hours,
limit if content_type == "parks" else limit * 2,
)
trending_items.extend(park_items)
if verbose:
self.stdout.write(f"Calculated {len(park_items)} trending parks")
if content_type in ["all", "rides"]:
ride_items = self._calculate_trending_rides(
current_period_hours,
previous_period_hours,
limit if content_type == "rides" else limit * 2,
)
trending_items.extend(ride_items)
if verbose:
self.stdout.write(f"Calculated {len(ride_items)} trending rides")
# Sort by trending score and apply limit
trending_items.sort(key=lambda x: x.get("trending_score", 0), reverse=True)
trending_items = trending_items[:limit]
# Format results for API consumption
formatted_results = self._format_trending_results(
trending_items, current_period_hours, previous_period_hours
)
# Cache results
cache_key = f"trending:calculated:{content_type}:{limit}"
cache.set(cache_key, formatted_results, 3600) # Cache for 1 hour
self.stdout.write(
self.style.SUCCESS(
f"Successfully calculated {len(formatted_results)} trending items for {content_type}"
)
)
if verbose:
for item in formatted_results[:5]: # Show first 5 items
self.stdout.write(
f" {item['name']} (score: {item.get('views_change', 'N/A')})"
)
except Exception as e:
logger.error(f"Error calculating trending content: {e}", exc_info=True)
raise CommandError(f"Failed to calculate trending content: {e}")
def _calculate_trending_parks(
self, current_period_hours: int, previous_period_hours: int, limit: int
) -> List[Dict[str, Any]]:
"""Calculate trending scores for parks using real data."""
parks = Park.objects.filter(status="OPERATING").select_related(
"location", "operator"
)
trending_parks = []
for park in parks:
try:
score = self._calculate_content_score(
park, "park", current_period_hours, previous_period_hours
)
if score > 0: # Only include items with positive trending scores
trending_parks.append(
{
"content_object": park,
"content_type": "park",
"trending_score": score,
"id": park.id,
"name": park.name,
"slug": park.slug,
"park": park.name, # For parks, park field is the park name itself
"category": "park",
"rating": (
float(park.average_rating)
if park.average_rating
else 0.0
),
"date_opened": (
park.opening_date.isoformat()
if park.opening_date
else ""
),
"url": park.url,
}
)
except Exception as e:
logger.warning(f"Error calculating score for park {park.id}: {e}")
return trending_parks
def _calculate_trending_rides(
self, current_period_hours: int, previous_period_hours: int, limit: int
) -> List[Dict[str, Any]]:
"""Calculate trending scores for rides using real data."""
rides = Ride.objects.filter(status="OPERATING").select_related(
"park", "park__location"
)
trending_rides = []
for ride in rides:
try:
score = self._calculate_content_score(
ride, "ride", current_period_hours, previous_period_hours
)
if score > 0: # Only include items with positive trending scores
trending_rides.append(
{
"content_object": ride,
"content_type": "ride",
"trending_score": score,
"id": ride.pk,
"name": ride.name,
"slug": ride.slug,
"park": ride.park.name if ride.park else "",
"category": "ride",
"rating": (
float(ride.average_rating)
if ride.average_rating
else 0.0
),
"date_opened": (
ride.opening_date.isoformat()
if ride.opening_date
else ""
),
"url": ride.url,
"park_url": ride.park.url if ride.park else "",
}
)
except Exception as e:
logger.warning(f"Error calculating score for ride {ride.pk}: {e}")
return trending_rides
def _calculate_content_score(
self,
content_obj: Any,
content_type: str,
current_period_hours: int,
previous_period_hours: int,
) -> float:
"""Calculate weighted trending score for content object using real analytics data."""
try:
# Get content type for PageView queries
ct = ContentType.objects.get_for_model(content_obj)
# 1. View Growth Score (40% weight)
view_growth_score = self._calculate_view_growth_score(
ct, content_obj.id, current_period_hours, previous_period_hours
)
# 2. Rating Score (30% weight)
rating_score = self._calculate_rating_score(content_obj)
# 3. Recency Score (20% weight)
recency_score = self._calculate_recency_score(content_obj)
# 4. Popularity Score (10% weight)
popularity_score = self._calculate_popularity_score(
ct, content_obj.id, current_period_hours
)
# Calculate weighted final score
final_score = (
view_growth_score * 0.4
+ rating_score * 0.3
+ recency_score * 0.2
+ popularity_score * 0.1
)
return final_score
except Exception as e:
logger.error(
f"Error calculating score for {content_type} {content_obj.id}: {e}"
)
return 0.0
def _calculate_view_growth_score(
self,
content_type: ContentType,
object_id: int,
current_period_hours: int,
previous_period_hours: int,
) -> float:
"""Calculate normalized view growth score using real PageView data."""
try:
current_views, previous_views, growth_percentage = (
PageView.get_views_growth(
content_type,
object_id,
current_period_hours,
previous_period_hours,
)
)
if previous_views == 0:
# New content with views gets boost
return min(current_views / 100.0, 1.0) if current_views > 0 else 0.0
# Normalize growth percentage to 0-1 scale
normalized_growth = (
min(growth_percentage / 500.0, 1.0) if growth_percentage > 0 else 0.0
)
return max(normalized_growth, 0.0)
except Exception as e:
logger.warning(f"Error calculating view growth: {e}")
return 0.0
def _calculate_rating_score(self, content_obj: Any) -> float:
"""Calculate normalized rating score."""
try:
rating = getattr(content_obj, "average_rating", None)
if rating is None or rating == 0:
return 0.3 # Neutral score for unrated content
# Normalize rating from 1-10 scale to 0-1 scale
return min(max((float(rating) - 1) / 9.0, 0.0), 1.0)
except Exception as e:
logger.warning(f"Error calculating rating score: {e}")
return 0.3
def _calculate_recency_score(self, content_obj: Any) -> float:
"""Calculate recency score based on when content was added/updated."""
try:
# Use opening_date for parks/rides, or created_at as fallback
date_added = getattr(content_obj, "opening_date", None)
if not date_added:
date_added = getattr(content_obj, "created_at", None)
if not date_added:
return 0.5 # Neutral score for unknown dates
# Handle both date and datetime objects
if hasattr(date_added, "date"):
date_added = date_added.date()
# Calculate days since added
today = timezone.now().date()
days_since_added = (today - date_added).days
# Recency score: newer content gets higher scores
if days_since_added <= 0:
return 1.0
elif days_since_added <= 30:
return 1.0 - (days_since_added / 30.0) * 0.2 # 1.0 to 0.8
elif days_since_added <= 365:
return 0.8 - ((days_since_added - 30) / (365 - 30)) * 0.7 # 0.8 to 0.1
else:
return 0.0
except Exception as e:
logger.warning(f"Error calculating recency score: {e}")
return 0.5
def _calculate_popularity_score(
self, content_type: ContentType, object_id: int, hours: int
) -> float:
"""Calculate popularity score based on total view count."""
try:
total_views = PageView.get_total_views_count(
content_type, object_id, hours=hours
)
# Normalize views to 0-1 scale
if total_views == 0:
return 0.0
elif total_views <= 100:
return total_views / 200.0 # 0.0 to 0.5
else:
return min(0.5 + (total_views - 100) / 1800.0, 1.0) # 0.5 to 1.0
except Exception as e:
logger.warning(f"Error calculating popularity score: {e}")
return 0.0
def _format_trending_results(
self,
trending_items: List[Dict[str, Any]],
current_period_hours: int,
previous_period_hours: int,
) -> List[Dict[str, Any]]:
"""Format trending results for frontend consumption."""
formatted_results = []
for rank, item in enumerate(trending_items, 1):
try:
# Get view change for display
content_obj = item["content_object"]
ct = ContentType.objects.get_for_model(content_obj)
current_views, previous_views, growth_percentage = (
PageView.get_views_growth(
ct,
content_obj.id,
current_period_hours,
previous_period_hours,
)
)
# Format exactly as frontend expects
formatted_item = {
"id": item["id"],
"name": item["name"],
"park": item["park"],
"category": item["category"],
"rating": item["rating"],
"rank": rank,
"views": current_views,
"views_change": (
f"+{growth_percentage:.1f}%"
if growth_percentage > 0
else f"{growth_percentage:.1f}%"
),
"slug": item["slug"],
"date_opened": item["date_opened"],
"url": item["url"],
}
# Add park_url for rides
if item.get("park_url"):
formatted_item["park_url"] = item["park_url"]
formatted_results.append(formatted_item)
except Exception as e:
logger.warning(f"Error formatting trending item: {e}")
return formatted_results

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +0,0 @@
# backend/apps/core/middleware.py
from django.utils.deprecation import MiddlewareMixin
class APIResponseMiddleware(MiddlewareMixin):
"""
Middleware to ensure consistent API responses for Next.js
"""
def process_response(self, request, response):
# Only process API requests
if not request.path.startswith("/api/"):
return response
# Ensure CORS headers are set
if not response.has_header("Access-Control-Allow-Origin"):
origin = request.META.get("HTTP_ORIGIN")
# Allow localhost/127.0.0.1 (any port) and IPv6 loopback for development
if origin:
import re
# support http or https, IPv4 and IPv6 loopback, any port
localhost_pattern = r"^https?://(localhost|127\.0\.0\.1|\[::1\]):\d+"
if re.match(localhost_pattern, origin):
response["Access-Control-Allow-Origin"] = origin
# Ensure caches vary by Origin
existing_vary = response.get("Vary")
if existing_vary:
response["Vary"] = f"{existing_vary}, Origin"
else:
response["Vary"] = "Origin"
# Helpful dev CORS headers (adjust for your frontend requests)
response["Access-Control-Allow-Methods"] = (
"GET, POST, PUT, PATCH, DELETE, OPTIONS"
)
response["Access-Control-Allow-Headers"] = (
"Authorization, Content-Type, X-Requested-With"
)
# Uncomment if your dev frontend needs to send cookies/auth credentials
# response['Access-Control-Allow-Credentials'] = 'true'
else:
response["Access-Control-Allow-Origin"] = "null"
return response

View File

@@ -1,138 +0,0 @@
"""
Request logging middleware for comprehensive request/response logging.
Logs all HTTP requests with detailed data for debugging and monitoring.
"""
import logging
import time
import json
from django.utils.deprecation import MiddlewareMixin
logger = logging.getLogger('request_logging')
class RequestLoggingMiddleware(MiddlewareMixin):
"""
Middleware to log all HTTP requests with method, path, and response code.
Includes detailed request/response data logging for all requests.
"""
# Paths to exclude from detailed logging (e.g., static files, health checks)
EXCLUDE_DETAILED_LOGGING_PATHS = [
'/static/',
'/media/',
'/favicon.ico',
'/health/',
'/admin/jsi18n/',
]
def _should_log_detailed(self, request):
"""Determine if detailed logging should be enabled for this request."""
return not any(
path in request.path for path in self.EXCLUDE_DETAILED_LOGGING_PATHS)
def process_request(self, request):
"""Store request start time and capture request data for detailed logging."""
request._start_time = time.time()
# Enable detailed logging for all requests except excluded paths
should_log_detailed = self._should_log_detailed(request)
request._log_request_data = should_log_detailed
if should_log_detailed:
try:
# Log request data
request_data = {}
if hasattr(request, 'data') and request.data:
request_data = dict(request.data)
elif request.body:
try:
request_data = json.loads(request.body.decode('utf-8'))
except (json.JSONDecodeError, UnicodeDecodeError):
request_data = {'body': str(request.body)[
:200] + '...' if len(str(request.body)) > 200 else str(request.body)}
# Log query parameters
query_params = dict(request.GET) if request.GET else {}
logger.info(f"REQUEST DATA for {request.method} {request.path}:")
if request_data:
logger.info(f" Body: {self._safe_log_data(request_data)}")
if query_params:
logger.info(f" Query: {query_params}")
if hasattr(request, 'user') and request.user.is_authenticated:
logger.info(
f" User: {request.user.username} (ID: {request.user.id})")
except Exception as e:
logger.warning(f"Failed to log request data: {e}")
return None
def process_response(self, request, response):
"""Log request details after response is generated."""
try:
# Calculate request duration
duration = 0
if hasattr(request, '_start_time'):
duration = time.time() - request._start_time
# Basic request logging
logger.info(
f"{request.method} {request.get_full_path()} -> {response.status_code} "
f"({duration:.3f}s)"
)
# Detailed response logging for specific endpoints
if getattr(request, '_log_request_data', False):
try:
# Log response data
if hasattr(response, 'data'):
logger.info(
f"RESPONSE DATA for {request.method} {request.path}:")
logger.info(f" Status: {response.status_code}")
logger.info(f" Data: {self._safe_log_data(response.data)}")
elif hasattr(response, 'content'):
try:
content = json.loads(response.content.decode('utf-8'))
logger.info(
f"RESPONSE DATA for {request.method} {request.path}:")
logger.info(f" Status: {response.status_code}")
logger.info(f" Content: {self._safe_log_data(content)}")
except (json.JSONDecodeError, UnicodeDecodeError):
logger.info(
f"RESPONSE DATA for {request.method} {request.path}:")
logger.info(f" Status: {response.status_code}")
logger.info(f" Content: {str(response.content)[:200]}...")
except Exception as e:
logger.warning(f"Failed to log response data: {e}")
except Exception:
# Don't let logging errors break the request
pass
return response
def _safe_log_data(self, data):
"""Safely log data, truncating if too large and masking sensitive fields."""
try:
# Convert to string representation
if isinstance(data, dict):
# Mask sensitive fields
safe_data = {}
for key, value in data.items():
if any(sensitive in key.lower() for sensitive in ['password', 'token', 'secret', 'key']):
safe_data[key] = '***MASKED***'
else:
safe_data[key] = value
data_str = json.dumps(safe_data, indent=2, default=str)
else:
data_str = json.dumps(data, indent=2, default=str)
# Truncate if too long
if len(data_str) > 1000:
return data_str[:1000] + '...[TRUNCATED]'
return data_str
except Exception:
return str(data)[:500] + '...[ERROR_LOGGING]'

View File

@@ -1,97 +0,0 @@
"""
Modern Security Headers Middleware for ThrillWiki
Implements Content Security Policy and other modern security headers.
"""
import secrets
import base64
from django.conf import settings
from django.utils.deprecation import MiddlewareMixin
class SecurityHeadersMiddleware(MiddlewareMixin):
"""
Middleware to add modern security headers to all responses.
"""
def _generate_nonce(self):
"""Generate a cryptographically secure nonce for CSP."""
# Generate 16 random bytes and encode as base64
return base64.b64encode(secrets.token_bytes(16)).decode('ascii')
def _modify_csp_with_nonce(self, csp_policy, nonce):
"""Modify CSP policy to include nonce for script-src."""
if not csp_policy:
return csp_policy
# Look for script-src directive and add nonce
directives = csp_policy.split(';')
modified_directives = []
for directive in directives:
directive = directive.strip()
if directive.startswith('script-src '):
# Add nonce to script-src directive
directive += f" 'nonce-{nonce}'"
modified_directives.append(directive)
return '; '.join(modified_directives)
def process_request(self, request):
"""Generate and store nonce for this request."""
# Generate a nonce for this request
nonce = self._generate_nonce()
# Store it in request so templates can access it
request.csp_nonce = nonce
return None
def process_response(self, request, response):
"""Add security headers to the response."""
# Content Security Policy with nonce support
if hasattr(settings, 'SECURE_CONTENT_SECURITY_POLICY'):
csp_policy = settings.SECURE_CONTENT_SECURITY_POLICY
# Apply nonce if we have one for this request
if hasattr(request, 'csp_nonce'):
csp_policy = self._modify_csp_with_nonce(csp_policy, request.csp_nonce)
response['Content-Security-Policy'] = csp_policy
# Cross-Origin Opener Policy
if hasattr(settings, 'SECURE_CROSS_ORIGIN_OPENER_POLICY'):
response['Cross-Origin-Opener-Policy'] = settings.SECURE_CROSS_ORIGIN_OPENER_POLICY
# Referrer Policy
if hasattr(settings, 'SECURE_REFERRER_POLICY'):
response['Referrer-Policy'] = settings.SECURE_REFERRER_POLICY
# Permissions Policy
if hasattr(settings, 'SECURE_PERMISSIONS_POLICY'):
response['Permissions-Policy'] = settings.SECURE_PERMISSIONS_POLICY
# Additional security headers
response['X-Content-Type-Options'] = 'nosniff'
response['X-Frame-Options'] = getattr(settings, 'X_FRAME_OPTIONS', 'DENY')
response['X-XSS-Protection'] = '1; mode=block'
# Cache Control headers for better performance
# Prevent caching of HTML pages to ensure users get fresh content
if response.get('Content-Type', '').startswith('text/html'):
response['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response['Pragma'] = 'no-cache'
response['Expires'] = '0'
# Strict Transport Security (if SSL is enabled)
if getattr(settings, 'SECURE_SSL_REDIRECT', False):
hsts_seconds = getattr(settings, 'SECURE_HSTS_SECONDS', 31536000)
hsts_include_subdomains = getattr(settings, 'SECURE_HSTS_INCLUDE_SUBDOMAINS', True)
hsts_preload = getattr(settings, 'SECURE_HSTS_PRELOAD', False)
hsts_header = f'max-age={hsts_seconds}'
if hsts_include_subdomains:
hsts_header += '; includeSubDomains'
if hsts_preload:
hsts_header += '; preload'
response['Strict-Transport-Security'] = hsts_header
return response

View File

@@ -1,292 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-21 01:27
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):
initial = True
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("pghistory", "0007_auto_20250421_0444"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="PageView",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("object_id", models.PositiveIntegerField()),
("timestamp", models.DateTimeField(auto_now_add=True, db_index=True)),
("ip_address", models.GenericIPAddressField()),
("user_agent", models.CharField(blank=True, max_length=512)),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="page_views",
to="contenttypes.contenttype",
),
),
],
),
migrations.CreateModel(
name="PageViewEvent",
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()),
("object_id", models.PositiveIntegerField()),
("timestamp", models.DateTimeField(auto_now_add=True)),
("ip_address", models.GenericIPAddressField()),
("user_agent", models.CharField(blank=True, max_length=512)),
(
"content_type",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
(
"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="core.pageview",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="SlugHistory",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("object_id", models.CharField(max_length=50)),
("old_slug", models.SlugField(max_length=200)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
],
options={
"verbose_name_plural": "Slug histories",
"ordering": ["-created_at"],
},
),
migrations.CreateModel(
name="SlugHistoryEvent",
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()),
("object_id", models.CharField(max_length=50)),
("old_slug", models.SlugField(db_index=False, max_length=200)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"content_type",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
(
"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="core.slughistory",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="HistoricalSlug",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("object_id", models.PositiveIntegerField()),
("slug", models.SlugField(max_length=255)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="historical_slugs",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"indexes": [
models.Index(
fields=["content_type", "object_id"],
name="core_histor_content_b4c470_idx",
),
models.Index(fields=["slug"], name="core_histor_slug_8fd7b3_idx"),
],
"unique_together": {("content_type", "slug")},
},
),
migrations.AddIndex(
model_name="pageview",
index=models.Index(
fields=["timestamp"], name="core_pagevi_timesta_757ebb_idx"
),
),
migrations.AddIndex(
model_name="pageview",
index=models.Index(
fields=["content_type", "object_id"],
name="core_pagevi_content_eda7ad_idx",
),
),
pgtrigger.migrations.AddTrigger(
model_name="pageview",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "core_pageviewevent" ("content_type_id", "id", "ip_address", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "timestamp", "user_agent") VALUES (NEW."content_type_id", NEW."id", NEW."ip_address", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."timestamp", NEW."user_agent"); RETURN NULL;',
hash="1682d124ea3ba215e630c7cfcde929f7444cf247",
operation="INSERT",
pgid="pgtrigger_insert_insert_ee1e1",
table="core_pageview",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="pageview",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "core_pageviewevent" ("content_type_id", "id", "ip_address", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "timestamp", "user_agent") VALUES (NEW."content_type_id", NEW."id", NEW."ip_address", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."timestamp", NEW."user_agent"); RETURN NULL;',
hash="4221b2dd6636cae454f8d69c0c1841c40c47e6a6",
operation="UPDATE",
pgid="pgtrigger_update_update_3c505",
table="core_pageview",
when="AFTER",
),
),
),
migrations.AddIndex(
model_name="slughistory",
index=models.Index(
fields=["content_type", "object_id"],
name="core_slughi_content_8bbf56_idx",
),
),
migrations.AddIndex(
model_name="slughistory",
index=models.Index(
fields=["old_slug"], name="core_slughi_old_slu_aaef7f_idx"
),
),
pgtrigger.migrations.AddTrigger(
model_name="slughistory",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "core_slughistoryevent" ("content_type_id", "created_at", "id", "object_id", "old_slug", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."id", NEW."object_id", NEW."old_slug", _pgh_attach_context(), NOW(), \'insert\', NEW."id"); RETURN NULL;',
hash="2a2a05025693c165b88e5eba7fcc23214749a78b",
operation="INSERT",
pgid="pgtrigger_insert_insert_3002a",
table="core_slughistory",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="slughistory",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "core_slughistoryevent" ("content_type_id", "created_at", "id", "object_id", "old_slug", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."id", NEW."object_id", NEW."old_slug", _pgh_attach_context(), NOW(), \'update\', NEW."id"); RETURN NULL;',
hash="3ad197ccb6178668e762720341e45d3fd3216776",
operation="UPDATE",
pgid="pgtrigger_update_update_52030",
table="core_slughistory",
when="AFTER",
),
),
),
]

View File

@@ -1,5 +0,0 @@
"""
Patches for third-party packages.
"""
# No patches currently applied

View File

@@ -1,148 +0,0 @@
"""
Media URL service for generating friendly URLs.
This service provides utilities for generating SEO-friendly URLs for media files
while maintaining compatibility with Cloudflare Images.
"""
import re
from typing import Optional, Dict, Any
from django.utils.text import slugify
class MediaURLService:
"""Service for generating and parsing friendly media URLs."""
@staticmethod
def generate_friendly_filename(caption: str, photo_id: int, extension: str = "jpg") -> str:
"""
Generate a friendly filename from photo caption and ID.
Args:
caption: Photo caption
photo_id: Photo database ID
extension: File extension (default: jpg)
Returns:
Friendly filename like "beautiful-park-entrance-123.jpg"
"""
if caption:
# Clean and slugify the caption
slug = slugify(caption)
# Limit length to avoid overly long URLs
if len(slug) > 50:
slug = slug[:50].rsplit('-', 1)[0] # Cut at word boundary
return f"{slug}-{photo_id}.{extension}"
else:
return f"photo-{photo_id}.{extension}"
@staticmethod
def generate_park_photo_url(park_slug: str, caption: str, photo_id: int, variant: str = "public") -> str:
"""
Generate a friendly URL for a park photo.
Args:
park_slug: Park slug
caption: Photo caption
photo_id: Photo database ID
variant: Image variant (public, thumbnail, medium, large)
Returns:
Friendly URL like "/parks/cedar-point/photos/beautiful-entrance-123.jpg"
"""
filename = MediaURLService.generate_friendly_filename(caption, photo_id)
# Add variant to filename if not public
if variant != "public":
name, ext = filename.rsplit('.', 1)
filename = f"{name}-{variant}.{ext}"
return f"/parks/{park_slug}/photos/{filename}"
@staticmethod
def generate_ride_photo_url(park_slug: str, ride_slug: str, caption: str, photo_id: int, variant: str = "public") -> str:
"""
Generate a friendly URL for a ride photo.
Args:
park_slug: Park slug
ride_slug: Ride slug
caption: Photo caption
photo_id: Photo database ID
variant: Image variant
Returns:
Friendly URL like "/parks/cedar-point/rides/millennium-force/photos/first-drop-456.jpg"
"""
filename = MediaURLService.generate_friendly_filename(caption, photo_id)
if variant != "public":
name, ext = filename.rsplit('.', 1)
filename = f"{name}-{variant}.{ext}"
return f"/parks/{park_slug}/rides/{ride_slug}/photos/{filename}"
@staticmethod
def parse_photo_filename(filename: str) -> Optional[Dict[str, Any]]:
"""
Parse a friendly filename to extract photo ID and variant.
Args:
filename: Filename like "beautiful-entrance-123-thumbnail.jpg"
Returns:
Dict with photo_id and variant, or None if parsing fails
"""
# Remove extension
name = filename.rsplit('.', 1)[0]
# Check for variant suffix
variant = "public"
variant_patterns = ["thumbnail", "medium", "large"]
for v in variant_patterns:
if name.endswith(f"-{v}"):
variant = v
name = name[:-len(f"-{v}")]
break
# Extract photo ID (should be the last number)
match = re.search(r'-(\d+)$', name)
if match:
photo_id = int(match.group(1))
return {
"photo_id": photo_id,
"variant": variant
}
return None
@staticmethod
def get_cloudflare_url_with_fallback(cloudflare_image, variant: str = "public") -> Optional[str]:
"""
Get Cloudflare URL with fallback handling.
Args:
cloudflare_image: CloudflareImage instance
variant: Desired variant
Returns:
Cloudflare URL or None
"""
if not cloudflare_image:
return None
try:
# Try the specific variant first
url = cloudflare_image.get_url(variant)
if url:
return url
# Fallback to public URL
if variant != "public":
return cloudflare_image.public_url
except Exception:
pass
return None

View File

@@ -1,5 +0,0 @@
"""
Core tasks package for ThrillWiki.
This package contains all Celery tasks for the core application.
"""

View File

@@ -1,607 +0,0 @@
"""
Trending calculation tasks for ThrillWiki.
This module contains Celery tasks for calculating and caching trending content.
All tasks run asynchronously to avoid blocking the main application.
"""
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any
from celery import shared_task
from django.utils import timezone
from django.core.cache import cache
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from apps.core.analytics import PageView
from apps.parks.models import Park
from apps.rides.models import Ride
logger = logging.getLogger(__name__)
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def calculate_trending_content(
self, content_type: str = "all", limit: int = 50
) -> Dict[str, Any]:
"""
Calculate trending content using real analytics data.
This task runs periodically to update trending calculations based on:
- View growth rates
- Content ratings
- Recency factors
- Popularity metrics
Args:
content_type: 'parks', 'rides', or 'all'
limit: Maximum number of results to calculate
Returns:
Dict containing trending results and metadata
"""
try:
logger.info(f"Starting trending calculation for {content_type}")
# Time windows for calculations
current_period_hours = 168 # 7 days
previous_period_hours = 336 # 14 days (for previous 7-day window comparison)
trending_items = []
if content_type in ["all", "parks"]:
park_items = _calculate_trending_parks(
current_period_hours,
previous_period_hours,
limit if content_type == "parks" else limit * 2,
)
trending_items.extend(park_items)
if content_type in ["all", "rides"]:
ride_items = _calculate_trending_rides(
current_period_hours,
previous_period_hours,
limit if content_type == "rides" else limit * 2,
)
trending_items.extend(ride_items)
# Sort by trending score and apply limit
trending_items.sort(key=lambda x: x.get("trending_score", 0), reverse=True)
trending_items = trending_items[:limit]
# Format results for API consumption
formatted_results = _format_trending_results(
trending_items, current_period_hours, previous_period_hours
)
# Cache results
cache_key = f"trending:calculated:{content_type}:{limit}"
cache.set(cache_key, formatted_results, 3600) # Cache for 1 hour
logger.info(
f"Calculated {len(formatted_results)} trending items for {content_type}"
)
return {
"success": True,
"content_type": content_type,
"count": len(formatted_results),
"results": formatted_results,
"calculated_at": timezone.now().isoformat(),
}
except Exception as e:
logger.error(f"Error calculating trending content: {e}", exc_info=True)
# Retry the task
raise self.retry(exc=e)
@shared_task(bind=True, max_retries=3, default_retry_delay=30)
def calculate_new_content(
self, content_type: str = "all", days_back: int = 30, limit: int = 50
) -> Dict[str, Any]:
"""
Calculate new content based on opening dates and creation dates.
Args:
content_type: 'parks', 'rides', or 'all'
days_back: How many days to look back for new content
limit: Maximum number of results
Returns:
Dict containing new content results and metadata
"""
try:
logger.info(f"Starting new content calculation for {content_type}")
cutoff_date = timezone.now() - timedelta(days=days_back)
new_items = []
if content_type in ["all", "parks"]:
parks = _get_new_parks(
cutoff_date, limit if content_type == "parks" else limit * 2
)
new_items.extend(parks)
if content_type in ["all", "rides"]:
rides = _get_new_rides(
cutoff_date, limit if content_type == "rides" else limit * 2
)
new_items.extend(rides)
# Sort by date added (most recent first) and apply limit
new_items.sort(key=lambda x: x.get("date_added", ""), reverse=True)
new_items = new_items[:limit]
# Format results for API consumption
formatted_results = _format_new_content_results(new_items)
# Cache results
cache_key = f"new_content:calculated:{content_type}:{days_back}:{limit}"
cache.set(cache_key, formatted_results, 1800) # Cache for 30 minutes
logger.info(f"Calculated {len(formatted_results)} new items for {content_type}")
return {
"success": True,
"content_type": content_type,
"count": len(formatted_results),
"results": formatted_results,
"calculated_at": timezone.now().isoformat(),
}
except Exception as e:
logger.error(f"Error calculating new content: {e}", exc_info=True)
raise self.retry(exc=e)
@shared_task(bind=True)
def warm_trending_cache(self) -> Dict[str, Any]:
"""
Warm the trending cache by pre-calculating common queries.
This task runs periodically to ensure fast API responses.
"""
try:
logger.info("Starting trending cache warming")
# Common query combinations to pre-calculate
queries = [
{"content_type": "all", "limit": 20},
{"content_type": "parks", "limit": 10},
{"content_type": "rides", "limit": 10},
{"content_type": "all", "limit": 50},
]
results = {}
for query in queries:
# Trigger trending calculation
calculate_trending_content.delay(**query)
# Trigger new content calculation
calculate_new_content.delay(**query)
results[f"trending_{query['content_type']}_{query['limit']}"] = "scheduled"
results[f"new_content_{query['content_type']}_{query['limit']}"] = (
"scheduled"
)
logger.info("Trending cache warming completed")
return {
"success": True,
"queries_scheduled": len(queries) * 2,
"results": results,
"warmed_at": timezone.now().isoformat(),
}
except Exception as e:
logger.error(f"Error warming trending cache: {e}", exc_info=True)
return {
"success": False,
"error": str(e),
"warmed_at": timezone.now().isoformat(),
}
def _calculate_trending_parks(
current_period_hours: int, previous_period_hours: int, limit: int
) -> List[Dict[str, Any]]:
"""Calculate trending scores for parks using real data."""
parks = Park.objects.filter(status="OPERATING").select_related(
"location", "operator"
)
trending_parks = []
for park in parks:
try:
score = _calculate_content_score(
park, "park", current_period_hours, previous_period_hours
)
if score > 0: # Only include items with positive trending scores
trending_parks.append(
{
"content_object": park,
"content_type": "park",
"trending_score": score,
"id": park.id,
"name": park.name,
"slug": park.slug,
"location": (
park.formatted_location if hasattr(park, "location") else ""
),
"category": "park",
"rating": (
float(park.average_rating) if park.average_rating else 0.0
),
}
)
except Exception as e:
logger.warning(f"Error calculating score for park {park.id}: {e}")
return trending_parks
def _calculate_trending_rides(
current_period_hours: int, previous_period_hours: int, limit: int
) -> List[Dict[str, Any]]:
"""Calculate trending scores for rides using real data."""
rides = Ride.objects.filter(status="OPERATING").select_related(
"park", "park__location"
)
trending_rides = []
for ride in rides:
try:
score = _calculate_content_score(
ride, "ride", current_period_hours, previous_period_hours
)
if score > 0: # Only include items with positive trending scores
# Get location from park
location = ""
if ride.park and hasattr(ride.park, "location") and ride.park.location:
location = ride.park.formatted_location
trending_rides.append(
{
"content_object": ride,
"content_type": "ride",
"trending_score": score,
"id": ride.pk,
"name": ride.name,
"slug": ride.slug,
"location": location,
"category": "ride",
"rating": (
float(ride.average_rating) if ride.average_rating else 0.0
),
}
)
except Exception as e:
logger.warning(f"Error calculating score for ride {ride.pk}: {e}")
return trending_rides
def _calculate_content_score(
content_obj: Any,
content_type: str,
current_period_hours: int,
previous_period_hours: int,
) -> float:
"""
Calculate weighted trending score for content object using real analytics data.
Algorithm Components:
- View Growth Rate (40% weight): Recent view increase vs historical
- Rating Score (30% weight): Average user rating normalized
- Recency Factor (20% weight): How recently content was added/updated
- Popularity Boost (10% weight): Total view count normalization
Returns:
Float between 0.0 and 1.0 representing trending strength
"""
try:
# Get content type for PageView queries
ct = ContentType.objects.get_for_model(content_obj)
# 1. View Growth Score (40% weight)
view_growth_score = _calculate_view_growth_score(
ct, content_obj.id, current_period_hours, previous_period_hours
)
# 2. Rating Score (30% weight)
rating_score = _calculate_rating_score(content_obj)
# 3. Recency Score (20% weight)
recency_score = _calculate_recency_score(content_obj)
# 4. Popularity Score (10% weight)
popularity_score = _calculate_popularity_score(
ct, content_obj.id, current_period_hours
)
# Calculate weighted final score
final_score = (
view_growth_score * 0.4
+ rating_score * 0.3
+ recency_score * 0.2
+ popularity_score * 0.1
)
logger.debug(
f"{content_type} {content_obj.id}: "
f"growth={view_growth_score:.3f}, rating={rating_score:.3f}, "
f"recency={recency_score:.3f}, popularity={popularity_score:.3f}, "
f"final={final_score:.3f}"
)
return final_score
except Exception as e:
logger.error(
f"Error calculating score for {content_type} {content_obj.id}: {e}"
)
return 0.0
def _calculate_view_growth_score(
content_type: ContentType,
object_id: int,
current_period_hours: int,
previous_period_hours: int,
) -> float:
"""Calculate normalized view growth score using real PageView data."""
try:
current_views, previous_views, growth_percentage = PageView.get_views_growth(
content_type,
object_id,
current_period_hours,
previous_period_hours,
)
if previous_views == 0:
# New content with views gets boost
return min(current_views / 100.0, 1.0) if current_views > 0 else 0.0
# Normalize growth percentage to 0-1 scale
# 100% growth = 0.5, 500% growth = 1.0
normalized_growth = (
min(growth_percentage / 500.0, 1.0) if growth_percentage > 0 else 0.0
)
return max(normalized_growth, 0.0)
except Exception as e:
logger.warning(f"Error calculating view growth: {e}")
return 0.0
def _calculate_rating_score(content_obj: Any) -> float:
"""Calculate normalized rating score."""
try:
rating = getattr(content_obj, "average_rating", None)
if rating is None or rating == 0:
return 0.3 # Neutral score for unrated content
# Normalize rating from 1-10 scale to 0-1 scale
# Rating of 5 = 0.4, Rating of 8 = 0.7, Rating of 10 = 1.0
return min(max((float(rating) - 1) / 9.0, 0.0), 1.0)
except Exception as e:
logger.warning(f"Error calculating rating score: {e}")
return 0.3
def _calculate_recency_score(content_obj: Any) -> float:
"""Calculate recency score based on when content was added/updated."""
try:
# Use opening_date for parks/rides, or created_at as fallback
date_added = getattr(content_obj, "opening_date", None)
if not date_added:
date_added = getattr(content_obj, "created_at", None)
if not date_added:
return 0.5 # Neutral score for unknown dates
# Handle both date and datetime objects
if hasattr(date_added, "date"):
date_added = date_added.date()
# Calculate days since added
today = timezone.now().date()
days_since_added = (today - date_added).days
# Recency score: newer content gets higher scores
# 0 days = 1.0, 30 days = 0.8, 365 days = 0.1, >365 days = 0.0
if days_since_added <= 0:
return 1.0
elif days_since_added <= 30:
return 1.0 - (days_since_added / 30.0) * 0.2 # 1.0 to 0.8
elif days_since_added <= 365:
return 0.8 - ((days_since_added - 30) / (365 - 30)) * 0.7 # 0.8 to 0.1
else:
return 0.0
except Exception as e:
logger.warning(f"Error calculating recency score: {e}")
return 0.5
def _calculate_popularity_score(
content_type: ContentType, object_id: int, hours: int
) -> float:
"""Calculate popularity score based on total view count."""
try:
total_views = PageView.get_total_views_count(
content_type, object_id, hours=hours
)
# Normalize views to 0-1 scale
# 0 views = 0.0, 100 views = 0.5, 1000+ views = 1.0
if total_views == 0:
return 0.0
elif total_views <= 100:
return total_views / 200.0 # 0.0 to 0.5
else:
return min(0.5 + (total_views - 100) / 1800.0, 1.0) # 0.5 to 1.0
except Exception as e:
logger.warning(f"Error calculating popularity score: {e}")
return 0.0
def _get_new_parks(cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]:
"""Get recently added parks using real data."""
new_parks = (
Park.objects.filter(
Q(created_at__gte=cutoff_date) | Q(opening_date__gte=cutoff_date.date()),
status="OPERATING",
)
.select_related("location", "operator")
.order_by("-created_at", "-opening_date")[:limit]
)
results = []
for park in new_parks:
date_added = park.opening_date or park.created_at
if date_added:
if isinstance(date_added, datetime):
date_added = date_added.date()
opening_date = getattr(park, "opening_date", None)
if opening_date and isinstance(opening_date, datetime):
opening_date = opening_date.date()
results.append(
{
"content_object": park,
"content_type": "park",
"id": park.pk,
"name": park.name,
"slug": park.slug,
"park": park.name, # For parks, park field is the park name itself
"category": "park",
"date_added": date_added.isoformat() if date_added else "",
"date_opened": opening_date.isoformat() if opening_date else "",
}
)
return results
def _get_new_rides(cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]:
"""Get recently added rides using real data."""
new_rides = (
Ride.objects.filter(
Q(created_at__gte=cutoff_date) | Q(opening_date__gte=cutoff_date.date()),
status="OPERATING",
)
.select_related("park", "park__location")
.order_by("-created_at", "-opening_date")[:limit]
)
results = []
for ride in new_rides:
date_added = getattr(ride, "opening_date", None) or getattr(
ride, "created_at", None
)
if date_added:
if isinstance(date_added, datetime):
date_added = date_added.date()
opening_date = getattr(ride, "opening_date", None)
if opening_date and isinstance(opening_date, datetime):
opening_date = opening_date.date()
results.append(
{
"content_object": ride,
"content_type": "ride",
"id": ride.pk,
"name": ride.name,
"slug": ride.slug,
"park": ride.park.name if ride.park else "",
"category": "ride",
"date_added": date_added.isoformat() if date_added else "",
"date_opened": opening_date.isoformat() if opening_date else "",
}
)
return results
def _format_trending_results(
trending_items: List[Dict[str, Any]],
current_period_hours: int,
previous_period_hours: int,
) -> List[Dict[str, Any]]:
"""Format trending results for frontend consumption."""
formatted_results = []
for rank, item in enumerate(trending_items, 1):
try:
# Get view change for display
content_obj = item["content_object"]
ct = ContentType.objects.get_for_model(content_obj)
current_views, previous_views, growth_percentage = (
PageView.get_views_growth(
ct,
content_obj.id,
current_period_hours,
previous_period_hours,
)
)
# Format exactly as frontend expects
formatted_item = {
"id": item["id"],
"name": item["name"],
"location": item["location"],
"category": item["category"],
"rating": item["rating"],
"rank": rank,
"views": current_views,
"views_change": (
f"+{growth_percentage:.1f}%"
if growth_percentage > 0
else f"{growth_percentage:.1f}%"
),
"slug": item["slug"],
}
formatted_results.append(formatted_item)
except Exception as e:
logger.warning(f"Error formatting trending item: {e}")
return formatted_results
def _format_new_content_results(
new_items: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Format new content results for frontend consumption."""
formatted_results = []
for item in new_items:
try:
# Format exactly as frontend expects
formatted_item = {
"id": item["id"],
"name": item["name"],
"park": item["park"],
"category": item["category"],
"date_added": item["date_added"],
"date_opened": item["date_opened"],
"slug": item["slug"],
}
formatted_results.append(formatted_item)
except Exception as e:
logger.warning(f"Error formatting new content item: {e}")
return formatted_results

View File

@@ -1,2 +0,0 @@
# Import choices to trigger auto-registration with the global registry
from . import choices # noqa: F401

View File

@@ -1,935 +0,0 @@
"""
Rich Choice Objects for Moderation Domain
This module defines all choice options for the moderation system using the Rich Choice Objects pattern.
All choices include rich metadata for UI styling, business logic, and enhanced functionality.
"""
from apps.core.choices.base import RichChoice, ChoiceCategory
from apps.core.choices.registry import register_choices
# ============================================================================
# EditSubmission Choices
# ============================================================================
EDIT_SUBMISSION_STATUSES = [
RichChoice(
value="PENDING",
label="Pending",
description="Submission awaiting moderator review",
metadata={
'color': 'yellow',
'icon': 'clock',
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
'sort_order': 1,
'can_transition_to': ['APPROVED', 'REJECTED', 'ESCALATED'],
'requires_moderator': True,
'is_actionable': True
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="APPROVED",
label="Approved",
description="Submission has been approved and changes applied",
metadata={
'color': 'green',
'icon': 'check-circle',
'css_class': 'bg-green-100 text-green-800 border-green-200',
'sort_order': 2,
'can_transition_to': [],
'requires_moderator': True,
'is_actionable': False,
'is_final': True
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="REJECTED",
label="Rejected",
description="Submission has been rejected and will not be applied",
metadata={
'color': 'red',
'icon': 'x-circle',
'css_class': 'bg-red-100 text-red-800 border-red-200',
'sort_order': 3,
'can_transition_to': [],
'requires_moderator': True,
'is_actionable': False,
'is_final': True
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="ESCALATED",
label="Escalated",
description="Submission has been escalated for higher-level review",
metadata={
'color': 'purple',
'icon': 'arrow-up',
'css_class': 'bg-purple-100 text-purple-800 border-purple-200',
'sort_order': 4,
'can_transition_to': ['APPROVED', 'REJECTED'],
'requires_moderator': True,
'is_actionable': True,
'escalation_level': 'admin'
},
category=ChoiceCategory.STATUS
),
]
SUBMISSION_TYPES = [
RichChoice(
value="EDIT",
label="Edit Existing",
description="Modification to existing content",
metadata={
'color': 'blue',
'icon': 'pencil',
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
'sort_order': 1,
'requires_existing_object': True,
'complexity_level': 'medium'
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="CREATE",
label="Create New",
description="Creation of new content",
metadata={
'color': 'green',
'icon': 'plus-circle',
'css_class': 'bg-green-100 text-green-800 border-green-200',
'sort_order': 2,
'requires_existing_object': False,
'complexity_level': 'high'
},
category=ChoiceCategory.CLASSIFICATION
),
]
# ============================================================================
# ModerationReport Choices
# ============================================================================
MODERATION_REPORT_STATUSES = [
RichChoice(
value="PENDING",
label="Pending Review",
description="Report awaiting initial moderator review",
metadata={
'color': 'yellow',
'icon': 'clock',
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
'sort_order': 1,
'can_transition_to': ['UNDER_REVIEW', 'DISMISSED'],
'requires_assignment': False,
'is_actionable': True
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="UNDER_REVIEW",
label="Under Review",
description="Report is actively being investigated by a moderator",
metadata={
'color': 'blue',
'icon': 'eye',
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
'sort_order': 2,
'can_transition_to': ['RESOLVED', 'DISMISSED'],
'requires_assignment': True,
'is_actionable': True
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="RESOLVED",
label="Resolved",
description="Report has been resolved with appropriate action taken",
metadata={
'color': 'green',
'icon': 'check-circle',
'css_class': 'bg-green-100 text-green-800 border-green-200',
'sort_order': 3,
'can_transition_to': [],
'requires_assignment': True,
'is_actionable': False,
'is_final': True
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="DISMISSED",
label="Dismissed",
description="Report was reviewed but no action was necessary",
metadata={
'color': 'gray',
'icon': 'x-circle',
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
'sort_order': 4,
'can_transition_to': [],
'requires_assignment': True,
'is_actionable': False,
'is_final': True
},
category=ChoiceCategory.STATUS
),
]
PRIORITY_LEVELS = [
RichChoice(
value="LOW",
label="Low",
description="Low priority - can be handled in regular workflow",
metadata={
'color': 'green',
'icon': 'arrow-down',
'css_class': 'bg-green-100 text-green-800 border-green-200',
'sort_order': 1,
'sla_hours': 168, # 7 days
'escalation_threshold': 240, # 10 days
'urgency_level': 1
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="MEDIUM",
label="Medium",
description="Medium priority - standard response time expected",
metadata={
'color': 'yellow',
'icon': 'minus',
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
'sort_order': 2,
'sla_hours': 72, # 3 days
'escalation_threshold': 120, # 5 days
'urgency_level': 2
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="HIGH",
label="High",
description="High priority - requires prompt attention",
metadata={
'color': 'orange',
'icon': 'arrow-up',
'css_class': 'bg-orange-100 text-orange-800 border-orange-200',
'sort_order': 3,
'sla_hours': 24, # 1 day
'escalation_threshold': 48, # 2 days
'urgency_level': 3
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="URGENT",
label="Urgent",
description="Urgent priority - immediate attention required",
metadata={
'color': 'red',
'icon': 'exclamation',
'css_class': 'bg-red-100 text-red-800 border-red-200',
'sort_order': 4,
'sla_hours': 4, # 4 hours
'escalation_threshold': 8, # 8 hours
'urgency_level': 4,
'requires_immediate_notification': True
},
category=ChoiceCategory.CLASSIFICATION
),
]
REPORT_TYPES = [
RichChoice(
value="SPAM",
label="Spam",
description="Unwanted or repetitive content",
metadata={
'color': 'yellow',
'icon': 'ban',
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
'sort_order': 1,
'default_priority': 'MEDIUM',
'auto_actions': ['content_review'],
'severity_level': 2
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="HARASSMENT",
label="Harassment",
description="Targeted harassment or bullying behavior",
metadata={
'color': 'red',
'icon': 'shield-exclamation',
'css_class': 'bg-red-100 text-red-800 border-red-200',
'sort_order': 2,
'default_priority': 'HIGH',
'auto_actions': ['user_review', 'content_review'],
'severity_level': 4,
'requires_user_action': True
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="INAPPROPRIATE_CONTENT",
label="Inappropriate Content",
description="Content that violates community guidelines",
metadata={
'color': 'orange',
'icon': 'exclamation-triangle',
'css_class': 'bg-orange-100 text-orange-800 border-orange-200',
'sort_order': 3,
'default_priority': 'HIGH',
'auto_actions': ['content_review'],
'severity_level': 3
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="MISINFORMATION",
label="Misinformation",
description="False or misleading information",
metadata={
'color': 'purple',
'icon': 'information-circle',
'css_class': 'bg-purple-100 text-purple-800 border-purple-200',
'sort_order': 4,
'default_priority': 'HIGH',
'auto_actions': ['content_review', 'fact_check'],
'severity_level': 3,
'requires_expert_review': True
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="COPYRIGHT",
label="Copyright Violation",
description="Unauthorized use of copyrighted material",
metadata={
'color': 'indigo',
'icon': 'document-duplicate',
'css_class': 'bg-indigo-100 text-indigo-800 border-indigo-200',
'sort_order': 5,
'default_priority': 'HIGH',
'auto_actions': ['content_review', 'legal_review'],
'severity_level': 4,
'requires_legal_review': True
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="PRIVACY",
label="Privacy Violation",
description="Unauthorized sharing of private information",
metadata={
'color': 'pink',
'icon': 'lock-closed',
'css_class': 'bg-pink-100 text-pink-800 border-pink-200',
'sort_order': 6,
'default_priority': 'URGENT',
'auto_actions': ['content_removal', 'user_review'],
'severity_level': 5,
'requires_immediate_action': True
},
category=ChoiceCategory.SECURITY
),
RichChoice(
value="HATE_SPEECH",
label="Hate Speech",
description="Content promoting hatred or discrimination",
metadata={
'color': 'red',
'icon': 'fire',
'css_class': 'bg-red-100 text-red-800 border-red-200',
'sort_order': 7,
'default_priority': 'URGENT',
'auto_actions': ['content_removal', 'user_suspension'],
'severity_level': 5,
'requires_immediate_action': True,
'zero_tolerance': True
},
category=ChoiceCategory.SECURITY
),
RichChoice(
value="VIOLENCE",
label="Violence or Threats",
description="Content containing violence or threatening behavior",
metadata={
'color': 'red',
'icon': 'exclamation',
'css_class': 'bg-red-100 text-red-800 border-red-200',
'sort_order': 8,
'default_priority': 'URGENT',
'auto_actions': ['content_removal', 'user_ban', 'law_enforcement_notification'],
'severity_level': 5,
'requires_immediate_action': True,
'zero_tolerance': True,
'requires_law_enforcement': True
},
category=ChoiceCategory.SECURITY
),
RichChoice(
value="OTHER",
label="Other",
description="Other issues not covered by specific categories",
metadata={
'color': 'gray',
'icon': 'dots-horizontal',
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
'sort_order': 9,
'default_priority': 'MEDIUM',
'auto_actions': ['manual_review'],
'severity_level': 1,
'requires_manual_categorization': True
},
category=ChoiceCategory.CLASSIFICATION
),
]
# ============================================================================
# ModerationQueue Choices
# ============================================================================
MODERATION_QUEUE_STATUSES = [
RichChoice(
value="PENDING",
label="Pending",
description="Queue item awaiting assignment or action",
metadata={
'color': 'yellow',
'icon': 'clock',
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
'sort_order': 1,
'can_transition_to': ['IN_PROGRESS', 'CANCELLED'],
'requires_assignment': False,
'is_actionable': True
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="IN_PROGRESS",
label="In Progress",
description="Queue item is actively being worked on",
metadata={
'color': 'blue',
'icon': 'play',
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
'sort_order': 2,
'can_transition_to': ['COMPLETED', 'CANCELLED'],
'requires_assignment': True,
'is_actionable': True
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="COMPLETED",
label="Completed",
description="Queue item has been successfully completed",
metadata={
'color': 'green',
'icon': 'check-circle',
'css_class': 'bg-green-100 text-green-800 border-green-200',
'sort_order': 3,
'can_transition_to': [],
'requires_assignment': True,
'is_actionable': False,
'is_final': True
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="CANCELLED",
label="Cancelled",
description="Queue item was cancelled and will not be completed",
metadata={
'color': 'gray',
'icon': 'x-circle',
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
'sort_order': 4,
'can_transition_to': [],
'requires_assignment': False,
'is_actionable': False,
'is_final': True
},
category=ChoiceCategory.STATUS
),
]
QUEUE_ITEM_TYPES = [
RichChoice(
value="CONTENT_REVIEW",
label="Content Review",
description="Review of user-submitted content for policy compliance",
metadata={
'color': 'blue',
'icon': 'document-text',
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
'sort_order': 1,
'estimated_time_minutes': 15,
'required_permissions': ['content_moderation'],
'complexity_level': 'medium'
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="USER_REVIEW",
label="User Review",
description="Review of user account or behavior",
metadata={
'color': 'purple',
'icon': 'user',
'css_class': 'bg-purple-100 text-purple-800 border-purple-200',
'sort_order': 2,
'estimated_time_minutes': 30,
'required_permissions': ['user_moderation'],
'complexity_level': 'high'
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="BULK_ACTION",
label="Bulk Action",
description="Large-scale administrative operation",
metadata={
'color': 'indigo',
'icon': 'collection',
'css_class': 'bg-indigo-100 text-indigo-800 border-indigo-200',
'sort_order': 3,
'estimated_time_minutes': 60,
'required_permissions': ['bulk_operations'],
'complexity_level': 'high'
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="POLICY_VIOLATION",
label="Policy Violation",
description="Investigation of potential policy violations",
metadata={
'color': 'red',
'icon': 'shield-exclamation',
'css_class': 'bg-red-100 text-red-800 border-red-200',
'sort_order': 4,
'estimated_time_minutes': 45,
'required_permissions': ['policy_enforcement'],
'complexity_level': 'high'
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="APPEAL",
label="Appeal",
description="Review of user appeal against moderation action",
metadata={
'color': 'orange',
'icon': 'scale',
'css_class': 'bg-orange-100 text-orange-800 border-orange-200',
'sort_order': 5,
'estimated_time_minutes': 30,
'required_permissions': ['appeal_review'],
'complexity_level': 'high'
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="OTHER",
label="Other",
description="Other moderation tasks not covered by specific types",
metadata={
'color': 'gray',
'icon': 'dots-horizontal',
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
'sort_order': 6,
'estimated_time_minutes': 20,
'required_permissions': ['general_moderation'],
'complexity_level': 'medium'
},
category=ChoiceCategory.CLASSIFICATION
),
]
# ============================================================================
# ModerationAction Choices
# ============================================================================
MODERATION_ACTION_TYPES = [
RichChoice(
value="WARNING",
label="Warning",
description="Formal warning issued to user",
metadata={
'color': 'yellow',
'icon': 'exclamation-triangle',
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
'sort_order': 1,
'severity_level': 1,
'is_temporary': False,
'affects_privileges': False,
'escalation_path': ['USER_SUSPENSION']
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="USER_SUSPENSION",
label="User Suspension",
description="Temporary suspension of user account",
metadata={
'color': 'orange',
'icon': 'pause',
'css_class': 'bg-orange-100 text-orange-800 border-orange-200',
'sort_order': 2,
'severity_level': 3,
'is_temporary': True,
'affects_privileges': True,
'requires_duration': True,
'escalation_path': ['USER_BAN']
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="USER_BAN",
label="User Ban",
description="Permanent ban of user account",
metadata={
'color': 'red',
'icon': 'ban',
'css_class': 'bg-red-100 text-red-800 border-red-200',
'sort_order': 3,
'severity_level': 5,
'is_temporary': False,
'affects_privileges': True,
'is_permanent': True,
'requires_admin_approval': True
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="CONTENT_REMOVAL",
label="Content Removal",
description="Removal of specific content",
metadata={
'color': 'red',
'icon': 'trash',
'css_class': 'bg-red-100 text-red-800 border-red-200',
'sort_order': 4,
'severity_level': 2,
'is_temporary': False,
'affects_privileges': False,
'is_content_action': True
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="CONTENT_EDIT",
label="Content Edit",
description="Modification of content to comply with policies",
metadata={
'color': 'blue',
'icon': 'pencil',
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
'sort_order': 5,
'severity_level': 1,
'is_temporary': False,
'affects_privileges': False,
'is_content_action': True,
'preserves_content': True
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="CONTENT_RESTRICTION",
label="Content Restriction",
description="Restriction of content visibility or access",
metadata={
'color': 'purple',
'icon': 'eye-off',
'css_class': 'bg-purple-100 text-purple-800 border-purple-200',
'sort_order': 6,
'severity_level': 2,
'is_temporary': True,
'affects_privileges': False,
'is_content_action': True,
'requires_duration': True
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="ACCOUNT_RESTRICTION",
label="Account Restriction",
description="Restriction of specific account privileges",
metadata={
'color': 'indigo',
'icon': 'lock-closed',
'css_class': 'bg-indigo-100 text-indigo-800 border-indigo-200',
'sort_order': 7,
'severity_level': 3,
'is_temporary': True,
'affects_privileges': True,
'requires_duration': True,
'escalation_path': ['USER_SUSPENSION']
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="OTHER",
label="Other",
description="Other moderation actions not covered by specific types",
metadata={
'color': 'gray',
'icon': 'dots-horizontal',
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
'sort_order': 8,
'severity_level': 1,
'is_temporary': False,
'affects_privileges': False,
'requires_manual_review': True
},
category=ChoiceCategory.CLASSIFICATION
),
]
# ============================================================================
# BulkOperation Choices
# ============================================================================
BULK_OPERATION_STATUSES = [
RichChoice(
value="PENDING",
label="Pending",
description="Operation is queued and waiting to start",
metadata={
'color': 'yellow',
'icon': 'clock',
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
'sort_order': 1,
'can_transition_to': ['RUNNING', 'CANCELLED'],
'is_actionable': True,
'can_cancel': True
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="RUNNING",
label="Running",
description="Operation is currently executing",
metadata={
'color': 'blue',
'icon': 'play',
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
'sort_order': 2,
'can_transition_to': ['COMPLETED', 'FAILED', 'CANCELLED'],
'is_actionable': True,
'can_cancel': True,
'shows_progress': True
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="COMPLETED",
label="Completed",
description="Operation completed successfully",
metadata={
'color': 'green',
'icon': 'check-circle',
'css_class': 'bg-green-100 text-green-800 border-green-200',
'sort_order': 3,
'can_transition_to': [],
'is_actionable': False,
'can_cancel': False,
'is_final': True
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="FAILED",
label="Failed",
description="Operation failed with errors",
metadata={
'color': 'red',
'icon': 'x-circle',
'css_class': 'bg-red-100 text-red-800 border-red-200',
'sort_order': 4,
'can_transition_to': [],
'is_actionable': False,
'can_cancel': False,
'is_final': True,
'requires_investigation': True
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="CANCELLED",
label="Cancelled",
description="Operation was cancelled before completion",
metadata={
'color': 'gray',
'icon': 'stop',
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
'sort_order': 5,
'can_transition_to': [],
'is_actionable': False,
'can_cancel': False,
'is_final': True
},
category=ChoiceCategory.STATUS
),
]
BULK_OPERATION_TYPES = [
RichChoice(
value="UPDATE_PARKS",
label="Update Parks",
description="Bulk update operations on park data",
metadata={
'color': 'green',
'icon': 'map',
'css_class': 'bg-green-100 text-green-800 border-green-200',
'sort_order': 1,
'estimated_duration_minutes': 30,
'required_permissions': ['bulk_park_operations'],
'affects_data': ['parks'],
'risk_level': 'medium'
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="UPDATE_RIDES",
label="Update Rides",
description="Bulk update operations on ride data",
metadata={
'color': 'blue',
'icon': 'cog',
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
'sort_order': 2,
'estimated_duration_minutes': 45,
'required_permissions': ['bulk_ride_operations'],
'affects_data': ['rides'],
'risk_level': 'medium'
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="IMPORT_DATA",
label="Import Data",
description="Import data from external sources",
metadata={
'color': 'purple',
'icon': 'download',
'css_class': 'bg-purple-100 text-purple-800 border-purple-200',
'sort_order': 3,
'estimated_duration_minutes': 60,
'required_permissions': ['data_import'],
'affects_data': ['parks', 'rides', 'users'],
'risk_level': 'high'
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="EXPORT_DATA",
label="Export Data",
description="Export data for backup or analysis",
metadata={
'color': 'indigo',
'icon': 'upload',
'css_class': 'bg-indigo-100 text-indigo-800 border-indigo-200',
'sort_order': 4,
'estimated_duration_minutes': 20,
'required_permissions': ['data_export'],
'affects_data': [],
'risk_level': 'low'
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="MODERATE_CONTENT",
label="Moderate Content",
description="Bulk moderation actions on content",
metadata={
'color': 'orange',
'icon': 'shield-check',
'css_class': 'bg-orange-100 text-orange-800 border-orange-200',
'sort_order': 5,
'estimated_duration_minutes': 40,
'required_permissions': ['bulk_moderation'],
'affects_data': ['content', 'users'],
'risk_level': 'high'
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="USER_ACTIONS",
label="User Actions",
description="Bulk actions on user accounts",
metadata={
'color': 'red',
'icon': 'users',
'css_class': 'bg-red-100 text-red-800 border-red-200',
'sort_order': 6,
'estimated_duration_minutes': 50,
'required_permissions': ['bulk_user_operations'],
'affects_data': ['users'],
'risk_level': 'high'
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="CLEANUP",
label="Cleanup",
description="System cleanup and maintenance operations",
metadata={
'color': 'gray',
'icon': 'trash',
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
'sort_order': 7,
'estimated_duration_minutes': 25,
'required_permissions': ['system_maintenance'],
'affects_data': ['system'],
'risk_level': 'low'
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="OTHER",
label="Other",
description="Other bulk operations not covered by specific types",
metadata={
'color': 'gray',
'icon': 'dots-horizontal',
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
'sort_order': 8,
'estimated_duration_minutes': 30,
'required_permissions': ['general_operations'],
'affects_data': [],
'risk_level': 'medium'
},
category=ChoiceCategory.TECHNICAL
),
]
# ============================================================================
# PhotoSubmission Choices (Shared with EditSubmission)
# ============================================================================
# PhotoSubmission uses the same STATUS_CHOICES as EditSubmission
PHOTO_SUBMISSION_STATUSES = EDIT_SUBMISSION_STATUSES
# ============================================================================
# Choice Registration
# ============================================================================
# Register all choice groups with the global registry
register_choices("edit_submission_statuses", EDIT_SUBMISSION_STATUSES, "moderation", "Edit submission status options")
register_choices("submission_types", SUBMISSION_TYPES, "moderation", "Submission type classifications")
register_choices("moderation_report_statuses", MODERATION_REPORT_STATUSES, "moderation", "Moderation report status options")
register_choices("priority_levels", PRIORITY_LEVELS, "moderation", "Priority level classifications")
register_choices("report_types", REPORT_TYPES, "moderation", "Report type classifications")
register_choices("moderation_queue_statuses", MODERATION_QUEUE_STATUSES, "moderation", "Moderation queue status options")
register_choices("queue_item_types", QUEUE_ITEM_TYPES, "moderation", "Queue item type classifications")
register_choices("moderation_action_types", MODERATION_ACTION_TYPES, "moderation", "Moderation action type classifications")
register_choices("bulk_operation_statuses", BULK_OPERATION_STATUSES, "moderation", "Bulk operation status options")
register_choices("bulk_operation_types", BULK_OPERATION_TYPES, "moderation", "Bulk operation type classifications")
register_choices("photo_submission_statuses", PHOTO_SUBMISSION_STATUSES, "moderation", "Photo submission status options")

View File

@@ -1,442 +0,0 @@
"""
Moderation Filters
This module contains Django filter classes for the moderation system,
providing comprehensive filtering capabilities for all moderation models.
"""
import django_filters
from django.contrib.auth import get_user_model
from django.db.models import Q
from django.utils import timezone
from datetime import timedelta
from .models import (
ModerationReport,
ModerationQueue,
ModerationAction,
BulkOperation,
)
from apps.core.choices.registry import get_choices
User = get_user_model()
class ModerationReportFilter(django_filters.FilterSet):
"""Filter for ModerationReport model."""
# Status filters
status = django_filters.ChoiceFilter(
choices=lambda: [(choice.value, choice.label) for choice in get_choices("moderation_report_statuses", "moderation")],
help_text="Filter by report status"
)
# Priority filters
priority = django_filters.ChoiceFilter(
choices=lambda: [(choice.value, choice.label) for choice in get_choices("priority_levels", "moderation")],
help_text="Filter by report priority"
)
# Report type filters
report_type = django_filters.ChoiceFilter(
choices=lambda: [(choice.value, choice.label) for choice in get_choices("report_types", "moderation")],
help_text="Filter by report type"
)
# User filters
reported_by = django_filters.ModelChoiceFilter(
queryset=User.objects.all(), help_text="Filter by user who made the report"
)
assigned_moderator = django_filters.ModelChoiceFilter(
queryset=User.objects.filter(role__in=["MODERATOR", "ADMIN", "SUPERUSER"]),
help_text="Filter by assigned moderator",
)
# Date filters
created_after = django_filters.DateTimeFilter(
field_name="created_at",
lookup_expr="gte",
help_text="Filter reports created after this date",
)
created_before = django_filters.DateTimeFilter(
field_name="created_at",
lookup_expr="lte",
help_text="Filter reports created before this date",
)
resolved_after = django_filters.DateTimeFilter(
field_name="resolved_at",
lookup_expr="gte",
help_text="Filter reports resolved after this date",
)
resolved_before = django_filters.DateTimeFilter(
field_name="resolved_at",
lookup_expr="lte",
help_text="Filter reports resolved before this date",
)
# Content type filters
content_type = django_filters.CharFilter(
field_name="content_type__model",
help_text="Filter by content type (e.g., 'park', 'ride', 'review')",
)
# Special filters
unassigned = django_filters.BooleanFilter(
method="filter_unassigned", help_text="Filter for unassigned reports"
)
overdue = django_filters.BooleanFilter(
method="filter_overdue", help_text="Filter for overdue reports based on SLA"
)
has_resolution = django_filters.BooleanFilter(
method="filter_has_resolution",
help_text="Filter reports with/without resolution",
)
class Meta:
model = ModerationReport
fields = [
"status",
"priority",
"report_type",
"reported_by",
"assigned_moderator",
"content_type",
"unassigned",
"overdue",
"has_resolution",
]
def filter_unassigned(self, queryset, name, value):
"""Filter for unassigned reports."""
if value:
return queryset.filter(assigned_moderator__isnull=True)
return queryset.filter(assigned_moderator__isnull=False)
def filter_overdue(self, queryset, name, value):
"""Filter for overdue reports based on SLA."""
if not value:
return queryset
now = timezone.now()
sla_hours = {"URGENT": 2, "HIGH": 8, "MEDIUM": 24, "LOW": 72}
overdue_ids = []
for report in queryset.filter(status__in=["PENDING", "UNDER_REVIEW"]):
hours_since_created = (now - report.created_at).total_seconds() / 3600
if report.priority in sla_hours:
threshold = sla_hours[report.priority]
else:
raise ValueError(f"Unknown priority level: {report.priority}")
if hours_since_created > threshold:
overdue_ids.append(report.id)
return queryset.filter(id__in=overdue_ids)
def filter_has_resolution(self, queryset, name, value):
"""Filter reports with/without resolution."""
if value:
return queryset.exclude(
resolution_action__isnull=True, resolution_action=""
)
return queryset.filter(
Q(resolution_action__isnull=True) | Q(resolution_action="")
)
class ModerationQueueFilter(django_filters.FilterSet):
"""Filter for ModerationQueue model."""
# Status filters
status = django_filters.ChoiceFilter(
choices=lambda: [(choice.value, choice.label) for choice in get_choices("moderation_queue_statuses", "moderation")],
help_text="Filter by queue item status"
)
# Priority filters
priority = django_filters.ChoiceFilter(
choices=lambda: [(choice.value, choice.label) for choice in get_choices("priority_levels", "moderation")],
help_text="Filter by queue item priority",
)
# Item type filters
item_type = django_filters.ChoiceFilter(
choices=lambda: [(choice.value, choice.label) for choice in get_choices("queue_item_types", "moderation")],
help_text="Filter by queue item type"
)
# Assignment filters
assigned_to = django_filters.ModelChoiceFilter(
queryset=User.objects.filter(role__in=["MODERATOR", "ADMIN", "SUPERUSER"]),
help_text="Filter by assigned moderator",
)
unassigned = django_filters.BooleanFilter(
method="filter_unassigned", help_text="Filter for unassigned queue items"
)
# Date filters
created_after = django_filters.DateTimeFilter(
field_name="created_at",
lookup_expr="gte",
help_text="Filter items created after this date",
)
created_before = django_filters.DateTimeFilter(
field_name="created_at",
lookup_expr="lte",
help_text="Filter items created before this date",
)
assigned_after = django_filters.DateTimeFilter(
field_name="assigned_at",
lookup_expr="gte",
help_text="Filter items assigned after this date",
)
assigned_before = django_filters.DateTimeFilter(
field_name="assigned_at",
lookup_expr="lte",
help_text="Filter items assigned before this date",
)
# Content type filters
content_type = django_filters.CharFilter(
field_name="content_type__model", help_text="Filter by content type"
)
# Related report filters
has_related_report = django_filters.BooleanFilter(
method="filter_has_related_report",
help_text="Filter items with/without related reports",
)
class Meta:
model = ModerationQueue
fields = [
"status",
"priority",
"item_type",
"assigned_to",
"unassigned",
"content_type",
"has_related_report",
]
def filter_unassigned(self, queryset, name, value):
"""Filter for unassigned queue items."""
if value:
return queryset.filter(assigned_to__isnull=True)
return queryset.filter(assigned_to__isnull=False)
def filter_has_related_report(self, queryset, name, value):
"""Filter items with/without related reports."""
if value:
return queryset.filter(related_report__isnull=False)
return queryset.filter(related_report__isnull=True)
class ModerationActionFilter(django_filters.FilterSet):
"""Filter for ModerationAction model."""
# Action type filters
action_type = django_filters.ChoiceFilter(
choices=lambda: [(choice.value, choice.label) for choice in get_choices("moderation_action_types", "moderation")],
help_text="Filter by action type"
)
# User filters
moderator = django_filters.ModelChoiceFilter(
queryset=User.objects.filter(role__in=["MODERATOR", "ADMIN", "SUPERUSER"]),
help_text="Filter by moderator who took the action",
)
target_user = django_filters.ModelChoiceFilter(
queryset=User.objects.all(), help_text="Filter by target user"
)
# Status filters
is_active = django_filters.BooleanFilter(help_text="Filter by active status")
# Date filters
created_after = django_filters.DateTimeFilter(
field_name="created_at",
lookup_expr="gte",
help_text="Filter actions created after this date",
)
created_before = django_filters.DateTimeFilter(
field_name="created_at",
lookup_expr="lte",
help_text="Filter actions created before this date",
)
expires_after = django_filters.DateTimeFilter(
field_name="expires_at",
lookup_expr="gte",
help_text="Filter actions expiring after this date",
)
expires_before = django_filters.DateTimeFilter(
field_name="expires_at",
lookup_expr="lte",
help_text="Filter actions expiring before this date",
)
# Special filters
expired = django_filters.BooleanFilter(
method="filter_expired", help_text="Filter for expired actions"
)
expiring_soon = django_filters.BooleanFilter(
method="filter_expiring_soon",
help_text="Filter for actions expiring within 24 hours",
)
has_related_report = django_filters.BooleanFilter(
method="filter_has_related_report",
help_text="Filter actions with/without related reports",
)
class Meta:
model = ModerationAction
fields = [
"action_type",
"moderator",
"target_user",
"is_active",
"expired",
"expiring_soon",
"has_related_report",
]
def filter_expired(self, queryset, name, value):
"""Filter for expired actions."""
now = timezone.now()
if value:
return queryset.filter(expires_at__lte=now)
return queryset.filter(Q(expires_at__gt=now) | Q(expires_at__isnull=True))
def filter_expiring_soon(self, queryset, name, value):
"""Filter for actions expiring within 24 hours."""
if not value:
return queryset
now = timezone.now()
soon = now + timedelta(hours=24)
return queryset.filter(expires_at__gt=now, expires_at__lte=soon, is_active=True)
def filter_has_related_report(self, queryset, name, value):
"""Filter actions with/without related reports."""
if value:
return queryset.filter(related_report__isnull=False)
return queryset.filter(related_report__isnull=True)
class BulkOperationFilter(django_filters.FilterSet):
"""Filter for BulkOperation model."""
# Status filters
status = django_filters.ChoiceFilter(
choices=lambda: [(choice.value, choice.label) for choice in get_choices("bulk_operation_statuses", "moderation")],
help_text="Filter by operation status"
)
# Operation type filters
operation_type = django_filters.ChoiceFilter(
choices=lambda: [(choice.value, choice.label) for choice in get_choices("bulk_operation_types", "moderation")],
help_text="Filter by operation type",
)
# Priority filters
priority = django_filters.ChoiceFilter(
choices=lambda: [(choice.value, choice.label) for choice in get_choices("priority_levels", "moderation")],
help_text="Filter by operation priority"
)
# User filters
created_by = django_filters.ModelChoiceFilter(
queryset=User.objects.filter(role__in=["ADMIN", "SUPERUSER"]),
help_text="Filter by user who created the operation",
)
# Date filters
created_after = django_filters.DateTimeFilter(
field_name="created_at",
lookup_expr="gte",
help_text="Filter operations created after this date",
)
created_before = django_filters.DateTimeFilter(
field_name="created_at",
lookup_expr="lte",
help_text="Filter operations created before this date",
)
started_after = django_filters.DateTimeFilter(
field_name="started_at",
lookup_expr="gte",
help_text="Filter operations started after this date",
)
started_before = django_filters.DateTimeFilter(
field_name="started_at",
lookup_expr="lte",
help_text="Filter operations started before this date",
)
completed_after = django_filters.DateTimeFilter(
field_name="completed_at",
lookup_expr="gte",
help_text="Filter operations completed after this date",
)
completed_before = django_filters.DateTimeFilter(
field_name="completed_at",
lookup_expr="lte",
help_text="Filter operations completed before this date",
)
# Special filters
can_cancel = django_filters.BooleanFilter(
help_text="Filter by cancellation capability"
)
has_failures = django_filters.BooleanFilter(
method="filter_has_failures",
help_text="Filter operations with/without failures",
)
in_progress = django_filters.BooleanFilter(
method="filter_in_progress",
help_text="Filter for operations currently in progress",
)
class Meta:
model = BulkOperation
fields = [
"status",
"operation_type",
"priority",
"created_by",
"can_cancel",
"has_failures",
"in_progress",
]
def filter_has_failures(self, queryset, name, value):
"""Filter operations with/without failures."""
if value:
return queryset.filter(failed_items__gt=0)
return queryset.filter(failed_items=0)
def filter_in_progress(self, queryset, name, value):
"""Filter for operations currently in progress."""
if value:
return queryset.filter(status__in=["PENDING", "RUNNING"])
return queryset.exclude(status__in=["PENDING", "RUNNING"])

File diff suppressed because it is too large Load Diff

View File

@@ -1,692 +0,0 @@
"""
Moderation Models
This module contains models for the ThrillWiki moderation system, including:
- EditSubmission: Original content submission and approval workflow
- ModerationReport: User reports for content moderation
- ModerationQueue: Workflow management for moderation tasks
- ModerationAction: Actions taken against users/content
- BulkOperation: Administrative bulk operations
All models use pghistory for change tracking and TrackedModel base class.
"""
from typing import Any, Dict, Optional, Union
from django.db import models
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.conf import settings
from django.utils import timezone
from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.models import AnonymousUser
from datetime import timedelta
import pghistory
from apps.core.history import TrackedModel
from apps.core.choices.fields import RichChoiceField
UserType = Union[AbstractBaseUser, AnonymousUser]
# ============================================================================
# Original EditSubmission Model (Preserved)
# ============================================================================
@pghistory.track() # Track all changes by default
class EditSubmission(TrackedModel):
# Who submitted the edit
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="edit_submissions",
)
# What is being edited (Park or Ride)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField(
null=True, blank=True
) # Null for new objects
content_object = GenericForeignKey("content_type", "object_id")
# Type of submission
submission_type = RichChoiceField(
choice_group="submission_types",
domain="moderation",
max_length=10,
default="EDIT"
)
# The actual changes/data
changes = models.JSONField(
help_text="JSON representation of the changes or new object data"
)
# Moderator's edited version of changes before approval
moderator_changes = models.JSONField(
null=True,
blank=True,
help_text="Moderator's edited version of the changes before approval",
)
# Metadata
reason = models.TextField(help_text="Why this edit/addition is needed")
source = models.TextField(
blank=True, help_text="Source of information (if applicable)"
)
status = RichChoiceField(
choice_group="edit_submission_statuses",
domain="moderation",
max_length=20,
default="PENDING"
)
created_at = models.DateTimeField(auto_now_add=True)
# Review details
handled_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="handled_submissions",
)
handled_at = models.DateTimeField(null=True, blank=True)
notes = models.TextField(
blank=True, help_text="Notes from the moderator about this submission"
)
class Meta(TrackedModel.Meta):
ordering = ["-created_at"]
indexes = [
models.Index(fields=["content_type", "object_id"]),
models.Index(fields=["status"]),
]
def __str__(self) -> str:
action = "creation" if self.submission_type == "CREATE" else "edit"
if model_class := self.content_type.model_class():
target = self.content_object or model_class.__name__
else:
target = "Unknown"
return f"{action} by {self.user.username} on {target}"
def _resolve_foreign_keys(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Convert foreign key IDs to model instances"""
if not (model_class := self.content_type.model_class()):
raise ValueError("Could not resolve model class")
resolved_data = data.copy()
for field_name, value in data.items():
try:
field = model_class._meta.get_field(field_name)
if isinstance(field, models.ForeignKey) and value is not None:
try:
related_obj = field.related_model.objects.get(pk=value) # type: ignore
resolved_data[field_name] = related_obj
except ObjectDoesNotExist:
raise ValueError(
f"Related object {field.related_model.__name__} with pk={value} does not exist" # type: ignore
)
except FieldDoesNotExist:
# Field doesn't exist on model, skip it
continue
return resolved_data
def _get_final_changes(self) -> Dict[str, Any]:
"""Get the final changes to apply (moderator changes if available, otherwise original changes)"""
return self.moderator_changes or self.changes
def approve(self, moderator: UserType) -> Optional[models.Model]:
"""
Approve this submission and apply the changes.
Args:
moderator: The user approving the submission
Returns:
The created or updated model instance
Raises:
ValueError: If submission cannot be approved
ValidationError: If the data is invalid
"""
if self.status != "PENDING":
raise ValueError(f"Cannot approve submission with status {self.status}")
model_class = self.content_type.model_class()
if not model_class:
raise ValueError("Could not resolve model class")
final_changes = self._get_final_changes()
resolved_changes = self._resolve_foreign_keys(final_changes)
try:
if self.submission_type == "CREATE":
# Create new object
obj = model_class(**resolved_changes)
obj.full_clean()
obj.save()
else:
# Update existing object
if not self.content_object:
raise ValueError("Cannot update: content object not found")
obj = self.content_object
for field_name, value in resolved_changes.items():
if hasattr(obj, field_name):
setattr(obj, field_name, value)
obj.full_clean()
obj.save()
# Mark submission as approved
self.status = "APPROVED"
self.handled_by = moderator
self.handled_at = timezone.now()
self.save()
return obj
except Exception as e:
# Mark as rejected on any error
self.status = "REJECTED"
self.handled_by = moderator
self.handled_at = timezone.now()
self.notes = f"Approval failed: {str(e)}"
self.save()
raise
def reject(self, moderator: UserType, reason: str) -> None:
"""
Reject this submission.
Args:
moderator: The user rejecting the submission
reason: Reason for rejection
"""
if self.status != "PENDING":
raise ValueError(f"Cannot reject submission with status {self.status}")
self.status = "REJECTED"
self.handled_by = moderator
self.handled_at = timezone.now()
self.notes = f"Rejected: {reason}"
self.save()
def escalate(self, moderator: UserType, reason: str) -> None:
"""
Escalate this submission for higher-level review.
Args:
moderator: The user escalating the submission
reason: Reason for escalation
"""
if self.status != "PENDING":
raise ValueError(f"Cannot escalate submission with status {self.status}")
self.status = "ESCALATED"
self.handled_by = moderator
self.handled_at = timezone.now()
self.notes = f"Escalated: {reason}"
self.save()
@property
def submitted_by(self):
"""Alias for user field to maintain compatibility"""
return self.user
@property
def submitted_at(self):
"""Alias for created_at field to maintain compatibility"""
return self.created_at
# ============================================================================
# New Moderation System Models
# ============================================================================
@pghistory.track()
class ModerationReport(TrackedModel):
"""
Model for tracking user reports about content, users, or behavior.
This handles the initial reporting phase where users flag content
or behavior that needs moderator attention.
"""
# Report details
report_type = RichChoiceField(
choice_group="report_types",
domain="moderation",
max_length=50
)
status = RichChoiceField(
choice_group="moderation_report_statuses",
domain="moderation",
max_length=20,
default='PENDING'
)
priority = RichChoiceField(
choice_group="priority_levels",
domain="moderation",
max_length=10,
default='MEDIUM'
)
# What is being reported
reported_entity_type = models.CharField(
max_length=50, help_text="Type of entity being reported (park, ride, user, etc.)")
reported_entity_id = models.PositiveIntegerField(
help_text="ID of the entity being reported")
content_type = models.ForeignKey(
ContentType, on_delete=models.CASCADE, null=True, blank=True)
# Report content
reason = models.CharField(max_length=200, help_text="Brief reason for the report")
description = models.TextField(help_text="Detailed description of the issue")
evidence_urls = models.JSONField(
default=list, blank=True, help_text="URLs to evidence (screenshots, etc.)")
# Users involved
reported_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='moderation_reports_made'
)
assigned_moderator = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='assigned_moderation_reports'
)
# Resolution
resolution_action = models.CharField(
max_length=100, blank=True, help_text="Action taken to resolve")
resolution_notes = models.TextField(
blank=True, help_text="Notes about the resolution")
resolved_at = models.DateTimeField(null=True, blank=True)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta(TrackedModel.Meta):
ordering = ['-created_at']
indexes = [
models.Index(fields=['status', 'priority']),
models.Index(fields=['reported_by']),
models.Index(fields=['assigned_moderator']),
models.Index(fields=['created_at']),
]
def __str__(self):
return f"{self.get_report_type_display()} report by {self.reported_by.username}" # type: ignore
@pghistory.track()
class ModerationQueue(TrackedModel):
"""
Model for managing moderation workflow and task assignment.
This represents items in the moderation queue that need attention,
separate from the initial reports.
"""
# Queue item details
item_type = RichChoiceField(
choice_group="queue_item_types",
domain="moderation",
max_length=50
)
status = RichChoiceField(
choice_group="moderation_queue_statuses",
domain="moderation",
max_length=20,
default='PENDING'
)
priority = RichChoiceField(
choice_group="priority_levels",
domain="moderation",
max_length=10,
default='MEDIUM'
)
title = models.CharField(max_length=200, help_text="Brief title for the queue item")
description = models.TextField(
help_text="Detailed description of what needs to be done")
# What entity this relates to
entity_type = models.CharField(
max_length=50, blank=True, help_text="Type of entity (park, ride, user, etc.)")
entity_id = models.PositiveIntegerField(
null=True, blank=True, help_text="ID of the related entity")
entity_preview = models.JSONField(
default=dict, blank=True, help_text="Preview data for the entity")
content_type = models.ForeignKey(
ContentType, on_delete=models.CASCADE, null=True, blank=True)
# Assignment and timing
assigned_to = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='assigned_queue_items'
)
assigned_at = models.DateTimeField(null=True, blank=True)
estimated_review_time = models.PositiveIntegerField(
default=30, help_text="Estimated time in minutes")
# Metadata
flagged_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='flagged_queue_items'
)
tags = models.JSONField(default=list, blank=True,
help_text="Tags for categorization")
# Related objects
related_report = models.ForeignKey(
ModerationReport,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='queue_items'
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta(TrackedModel.Meta):
ordering = ['priority', 'created_at']
indexes = [
models.Index(fields=['status', 'priority']),
models.Index(fields=['assigned_to']),
models.Index(fields=['created_at']),
]
def __str__(self):
return f"{self.get_item_type_display()}: {self.title}" # type: ignore
@pghistory.track()
class ModerationAction(TrackedModel):
"""
Model for tracking actions taken against users or content.
This records what actions moderators have taken, including
warnings, suspensions, content removal, etc.
"""
# Action details
action_type = RichChoiceField(
choice_group="moderation_action_types",
domain="moderation",
max_length=50
)
reason = models.CharField(max_length=200, help_text="Brief reason for the action")
details = models.TextField(help_text="Detailed explanation of the action")
# Duration (for temporary actions)
duration_hours = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Duration in hours for temporary actions"
)
expires_at = models.DateTimeField(
null=True, blank=True, help_text="When this action expires")
is_active = models.BooleanField(
default=True, help_text="Whether this action is currently active")
# Users involved
moderator = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='moderation_actions_taken'
)
target_user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='moderation_actions_received'
)
# Related objects
related_report = models.ForeignKey(
ModerationReport,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='actions_taken'
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta(TrackedModel.Meta):
ordering = ['-created_at']
indexes = [
models.Index(fields=['target_user', 'is_active']),
models.Index(fields=['moderator']),
models.Index(fields=['expires_at']),
models.Index(fields=['created_at']),
]
def __str__(self):
return f"{self.get_action_type_display()} against {self.target_user.username} by {self.moderator.username}" # type: ignore
def save(self, *args, **kwargs):
# Set expiration time if duration is provided
if self.duration_hours and not self.expires_at:
self.expires_at = timezone.now() + timedelta(hours=self.duration_hours)
super().save(*args, **kwargs)
@pghistory.track()
class BulkOperation(TrackedModel):
"""
Model for tracking bulk administrative operations.
This handles large-scale operations like bulk updates,
imports, exports, or mass moderation actions.
"""
# Operation details
operation_type = RichChoiceField(
choice_group="bulk_operation_types",
domain="moderation",
max_length=50
)
status = RichChoiceField(
choice_group="bulk_operation_statuses",
domain="moderation",
max_length=20,
default='PENDING'
)
priority = RichChoiceField(
choice_group="priority_levels",
domain="moderation",
max_length=10,
default='MEDIUM'
)
description = models.TextField(help_text="Description of what this operation does")
# Operation parameters and results
parameters = models.JSONField(
default=dict, help_text="Parameters for the operation")
results = models.JSONField(default=dict, blank=True,
help_text="Results and output from the operation")
# Progress tracking
total_items = models.PositiveIntegerField(
default=0, help_text="Total number of items to process")
processed_items = models.PositiveIntegerField(
default=0, help_text="Number of items processed")
failed_items = models.PositiveIntegerField(
default=0, help_text="Number of items that failed")
# Timing
estimated_duration_minutes = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Estimated duration in minutes"
)
schedule_for = models.DateTimeField(
null=True, blank=True, help_text="When to run this operation")
# Control
can_cancel = models.BooleanField(
default=True, help_text="Whether this operation can be cancelled")
# User who created the operation
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='bulk_operations_created'
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
started_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta(TrackedModel.Meta):
ordering = ['-created_at']
indexes = [
models.Index(fields=['status', 'priority']),
models.Index(fields=['created_by']),
models.Index(fields=['schedule_for']),
models.Index(fields=['created_at']),
]
def __str__(self):
return f"{self.get_operation_type_display()}: {self.description[:50]}" # type: ignore
@property
def progress_percentage(self):
"""Calculate progress percentage."""
if self.total_items == 0:
return 0.0
return round((self.processed_items / self.total_items) * 100, 2)
@pghistory.track() # Track all changes by default
class PhotoSubmission(TrackedModel):
# Who submitted the photo
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="photo_submissions",
)
# What the photo is for (Park or Ride)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey("content_type", "object_id")
# The photo itself
photo = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
on_delete=models.CASCADE,
help_text="Photo submission stored on Cloudflare Images"
)
caption = models.CharField(max_length=255, blank=True)
date_taken = models.DateField(null=True, blank=True)
# Metadata
status = RichChoiceField(
choice_group="photo_submission_statuses",
domain="moderation",
max_length=20,
default="PENDING"
)
created_at = models.DateTimeField(auto_now_add=True)
# Review details
handled_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="handled_photos",
)
handled_at = models.DateTimeField(null=True, blank=True)
notes = models.TextField(
blank=True,
help_text="Notes from the moderator about this photo submission",
)
class Meta(TrackedModel.Meta):
ordering = ["-created_at"]
indexes = [
models.Index(fields=["content_type", "object_id"]),
models.Index(fields=["status"]),
]
def __str__(self) -> str:
return f"Photo submission by {self.user.username} for {self.content_object}"
def approve(self, moderator: UserType, notes: str = "") -> None:
"""Approve the photo submission"""
from apps.parks.models.media import ParkPhoto
from apps.rides.models.media import RidePhoto
self.status = "APPROVED"
self.handled_by = moderator # type: ignore
self.handled_at = timezone.now()
self.notes = notes
# Determine the correct photo model based on the content type
model_class = self.content_type.model_class()
if model_class.__name__ == "Park":
PhotoModel = ParkPhoto
elif model_class.__name__ == "Ride":
PhotoModel = RidePhoto
else:
raise ValueError(f"Unsupported content type: {model_class.__name__}")
# Create the approved photo
PhotoModel.objects.create(
uploaded_by=self.user,
content_object=self.content_object,
image=self.photo,
caption=self.caption,
is_approved=True,
)
self.save()
def reject(self, moderator: UserType, notes: str) -> None:
"""Reject the photo submission"""
self.status = "REJECTED"
self.handled_by = moderator # type: ignore
self.handled_at = timezone.now()
self.notes = notes
self.save()
def auto_approve(self) -> None:
"""Auto - approve submissions from moderators"""
# Get user role safely
user_role = getattr(self.user, "role", None)
# If user is moderator or above, auto-approve
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
self.approve(self.user)
def escalate(self, moderator: UserType, notes: str = "") -> None:
"""Escalate the photo submission to admin"""
self.status = "ESCALATED"
self.handled_by = moderator # type: ignore
self.handled_at = timezone.now()
self.notes = notes
self.save()

View File

@@ -1,318 +0,0 @@
"""
Moderation Permissions
This module contains custom permission classes for the moderation system,
providing role-based access control for moderation operations.
"""
from rest_framework import permissions
from django.contrib.auth import get_user_model
User = get_user_model()
class IsModerator(permissions.BasePermission):
"""
Permission that only allows moderators to access the view.
"""
def has_permission(self, request, view):
"""Check if user is authenticated and has moderator role."""
if not request.user or not request.user.is_authenticated:
return False
user_role = getattr(request.user, "role", "USER")
return user_role == "MODERATOR"
def has_object_permission(self, request, view, obj):
"""Check object-level permissions for moderators."""
return self.has_permission(request, view)
class IsModeratorOrAdmin(permissions.BasePermission):
"""
Permission that allows moderators, admins, and superusers to access the view.
"""
def has_permission(self, request, view):
"""Check if user is authenticated and has moderator, admin, or superuser role."""
if not request.user or not request.user.is_authenticated:
return False
user_role = getattr(request.user, "role", "USER")
return user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]
def has_object_permission(self, request, view, obj):
"""Check object-level permissions for moderators and admins."""
return self.has_permission(request, view)
class IsAdminOrSuperuser(permissions.BasePermission):
"""
Permission that only allows admins and superusers to access the view.
"""
def has_permission(self, request, view):
"""Check if user is authenticated and has admin or superuser role."""
if not request.user or not request.user.is_authenticated:
return False
user_role = getattr(request.user, "role", "USER")
return user_role in ["ADMIN", "SUPERUSER"]
def has_object_permission(self, request, view, obj):
"""Check object-level permissions for admins and superusers."""
return self.has_permission(request, view)
class CanViewModerationData(permissions.BasePermission):
"""
Permission that allows users to view moderation data based on their role.
- Regular users can only view their own reports
- Moderators and above can view all moderation data
"""
def has_permission(self, request, view):
"""Check if user is authenticated."""
return request.user and request.user.is_authenticated
def has_object_permission(self, request, view, obj):
"""Check object-level permissions for viewing moderation data."""
if not request.user or not request.user.is_authenticated:
return False
user_role = getattr(request.user, "role", "USER")
# Moderators and above can view all data
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
return True
# Regular users can only view their own reports
if hasattr(obj, "reported_by"):
return obj.reported_by == request.user
# For other objects, deny access to regular users
return False
class CanModerateContent(permissions.BasePermission):
"""
Permission that allows users to moderate content based on their role.
- Only moderators and above can moderate content
- Includes additional checks for specific moderation actions
"""
def has_permission(self, request, view):
"""Check if user is authenticated and has moderation privileges."""
if not request.user or not request.user.is_authenticated:
return False
user_role = getattr(request.user, "role", "USER")
return user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]
def has_object_permission(self, request, view, obj):
"""Check object-level permissions for content moderation."""
if not self.has_permission(request, view):
return False
user_role = getattr(request.user, "role", "USER")
# Superusers can do everything
if user_role == "SUPERUSER":
return True
# Admins can moderate most content but may have some restrictions
if user_role == "ADMIN":
# Add any admin-specific restrictions here if needed
return True
# Moderators have basic moderation permissions
if user_role == "MODERATOR":
# Add any moderator-specific restrictions here if needed
# For example, moderators might not be able to moderate admin actions
if hasattr(obj, "moderator") and obj.moderator:
moderator_role = getattr(obj.moderator, "role", "USER")
if moderator_role in ["ADMIN", "SUPERUSER"]:
return False
return True
return False
class CanAssignModerationTasks(permissions.BasePermission):
"""
Permission that allows users to assign moderation tasks to others.
- Moderators can assign tasks to themselves
- Admins can assign tasks to moderators and themselves
- Superusers can assign tasks to anyone
"""
def has_permission(self, request, view):
"""Check if user is authenticated and has assignment privileges."""
if not request.user or not request.user.is_authenticated:
return False
user_role = getattr(request.user, "role", "USER")
return user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]
def has_object_permission(self, request, view, obj):
"""Check object-level permissions for task assignment."""
if not self.has_permission(request, view):
return False
user_role = getattr(request.user, "role", "USER")
# Superusers can assign to anyone
if user_role == "SUPERUSER":
return True
# Admins can assign to moderators and themselves
if user_role == "ADMIN":
return True
# Moderators can only assign to themselves
if user_role == "MODERATOR":
# Check if they're trying to assign to themselves
assignee_id = request.data.get("moderator_id") or request.data.get(
"assigned_to"
)
if assignee_id:
return str(assignee_id) == str(request.user.id)
return True
return False
class CanPerformBulkOperations(permissions.BasePermission):
"""
Permission that allows users to perform bulk operations.
- Only admins and superusers can perform bulk operations
- Includes additional safety checks for destructive operations
"""
def has_permission(self, request, view):
"""Check if user is authenticated and has bulk operation privileges."""
if not request.user or not request.user.is_authenticated:
return False
user_role = getattr(request.user, "role", "USER")
return user_role in ["ADMIN", "SUPERUSER"]
def has_object_permission(self, request, view, obj):
"""Check object-level permissions for bulk operations."""
if not self.has_permission(request, view):
return False
user_role = getattr(request.user, "role", "USER")
# Superusers can perform all bulk operations
if user_role == "SUPERUSER":
return True
# Admins can perform most bulk operations
if user_role == "ADMIN":
# Add any admin-specific restrictions for bulk operations here
# For example, admins might not be able to perform certain destructive operations
operation_type = getattr(obj, "operation_type", None)
if operation_type in ["DELETE_USERS", "PURGE_DATA"]:
return False # Only superusers can perform these operations
return True
return False
class IsOwnerOrModerator(permissions.BasePermission):
"""
Permission that allows object owners or moderators to access the view.
- Users can access their own objects
- Moderators and above can access any object
"""
def has_permission(self, request, view):
"""Check if user is authenticated."""
return request.user and request.user.is_authenticated
def has_object_permission(self, request, view, obj):
"""Check object-level permissions for owners or moderators."""
if not request.user or not request.user.is_authenticated:
return False
user_role = getattr(request.user, "role", "USER")
# Moderators and above can access any object
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
return True
# Check if user is the owner of the object
if hasattr(obj, "reported_by"):
return obj.reported_by == request.user
elif hasattr(obj, "created_by"):
return obj.created_by == request.user
elif hasattr(obj, "user"):
return obj.user == request.user
return False
class CanManageUserRestrictions(permissions.BasePermission):
"""
Permission that allows users to manage user restrictions and moderation actions.
- Moderators can create basic restrictions (warnings, temporary suspensions)
- Admins can create more severe restrictions (longer suspensions, content removal)
- Superusers can create any restriction including permanent bans
"""
def has_permission(self, request, view):
"""Check if user is authenticated and has restriction management privileges."""
if not request.user or not request.user.is_authenticated:
return False
user_role = getattr(request.user, "role", "USER")
return user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]
def has_object_permission(self, request, view, obj):
"""Check object-level permissions for managing user restrictions."""
if not self.has_permission(request, view):
return False
user_role = getattr(request.user, "role", "USER")
# Superusers can manage any restriction
if user_role == "SUPERUSER":
return True
# Get the action type from request data or object
action_type = None
if request.method in ["POST", "PUT", "PATCH"]:
action_type = request.data.get("action_type")
elif hasattr(obj, "action_type"):
action_type = obj.action_type
# Admins can manage most restrictions
if user_role == "ADMIN":
# Admins cannot create permanent bans
if action_type == "USER_BAN" and request.data.get("duration_hours") is None:
return False
return True
# Moderators can only manage basic restrictions
if user_role == "MODERATOR":
allowed_actions = ["WARNING", "CONTENT_REMOVAL", "USER_SUSPENSION"]
if action_type not in allowed_actions:
return False
# Moderators can only create temporary suspensions (max 7 days)
if action_type == "USER_SUSPENSION":
duration_hours = request.data.get("duration_hours", 0)
if duration_hours > 168: # 7 days = 168 hours
return False
return True
return False

View File

@@ -1,747 +0,0 @@
"""
Moderation API Serializers
This module contains DRF serializers for the moderation system, including:
- ModerationReport serializers for content reporting
- ModerationQueue serializers for moderation workflow
- ModerationAction serializers for tracking moderation actions
- BulkOperation serializers for administrative bulk operations
All serializers include comprehensive validation and nested relationships.
"""
from rest_framework import serializers
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from datetime import timedelta
from .models import (
ModerationReport,
ModerationQueue,
ModerationAction,
BulkOperation,
)
User = get_user_model()
# ============================================================================
# Base Serializers
# ============================================================================
class UserBasicSerializer(serializers.ModelSerializer):
"""Basic user information for moderation contexts."""
display_name = serializers.SerializerMethodField()
class Meta:
model = User
fields = ["id", "username", "display_name", "email", "role"]
read_only_fields = ["id", "username", "display_name", "email", "role"]
def get_display_name(self, obj):
"""Get the user's display name."""
return obj.get_display_name()
class ContentTypeSerializer(serializers.ModelSerializer):
"""Content type information for generic foreign keys."""
class Meta:
model = ContentType
fields = ["id", "app_label", "model"]
read_only_fields = ["id", "app_label", "model"]
# ============================================================================
# Moderation Report Serializers
# ============================================================================
class ModerationReportSerializer(serializers.ModelSerializer):
"""Full moderation report serializer with all details."""
reported_by = UserBasicSerializer(read_only=True)
assigned_moderator = UserBasicSerializer(read_only=True)
content_type = ContentTypeSerializer(read_only=True)
# Computed fields
is_overdue = serializers.SerializerMethodField()
time_since_created = serializers.SerializerMethodField()
priority_display = serializers.CharField(
source="get_priority_display", read_only=True
)
status_display = serializers.CharField(source="get_status_display", read_only=True)
report_type_display = serializers.CharField(
source="get_report_type_display", read_only=True
)
class Meta:
model = ModerationReport
fields = [
"id",
"report_type",
"report_type_display",
"status",
"status_display",
"priority",
"priority_display",
"reported_entity_type",
"reported_entity_id",
"reason",
"description",
"evidence_urls",
"resolved_at",
"resolution_notes",
"resolution_action",
"created_at",
"updated_at",
"reported_by",
"assigned_moderator",
"content_type",
"is_overdue",
"time_since_created",
]
read_only_fields = [
"id",
"created_at",
"updated_at",
"reported_by",
"content_type",
"is_overdue",
"time_since_created",
"report_type_display",
"status_display",
"priority_display",
]
def get_is_overdue(self, obj) -> bool:
"""Check if report is overdue based on priority."""
if obj.status in ["RESOLVED", "DISMISSED"]:
return False
now = timezone.now()
hours_since_created = (now - obj.created_at).total_seconds() / 3600
# Define SLA hours by priority
sla_hours = {"URGENT": 2, "HIGH": 8, "MEDIUM": 24, "LOW": 72}
if obj.priority in sla_hours:
threshold = sla_hours[obj.priority]
else:
raise ValueError(f"Unknown priority level: {obj.priority}")
return hours_since_created > threshold
def get_time_since_created(self, obj) -> str:
"""Human-readable time since creation."""
now = timezone.now()
diff = now - obj.created_at
if diff.days > 0:
return f"{diff.days} days ago"
elif diff.seconds > 3600:
hours = diff.seconds // 3600
return f"{hours} hours ago"
else:
minutes = diff.seconds // 60
return f"{minutes} minutes ago"
class CreateModerationReportSerializer(serializers.ModelSerializer):
"""Serializer for creating new moderation reports."""
class Meta:
model = ModerationReport
fields = [
"report_type",
"reported_entity_type",
"reported_entity_id",
"reason",
"description",
"evidence_urls",
]
def validate(self, attrs):
"""Validate the report data."""
# Validate entity type
valid_entity_types = ["park", "ride", "review", "photo", "user", "comment"]
if attrs["reported_entity_type"] not in valid_entity_types:
raise serializers.ValidationError(
{
"reported_entity_type": f'Must be one of: {", ".join(valid_entity_types)}'
}
)
# Validate evidence URLs
evidence_urls = attrs.get("evidence_urls", [])
if not isinstance(evidence_urls, list):
raise serializers.ValidationError(
{"evidence_urls": "Must be a list of URLs"}
)
return attrs
def create(self, validated_data):
"""Create a new moderation report."""
validated_data["reported_by"] = self.context["request"].user
validated_data["status"] = "PENDING"
validated_data["priority"] = "MEDIUM" # Default priority
# Set content type based on entity type
entity_type = validated_data["reported_entity_type"]
app_label_map = {
"park": "parks",
"ride": "rides",
"review": "rides", # Assuming ride reviews
"photo": "media",
"user": "accounts",
"comment": "core",
}
if entity_type in app_label_map:
try:
content_type = ContentType.objects.get(
app_label=app_label_map[entity_type], model=entity_type
)
validated_data["content_type"] = content_type
except ContentType.DoesNotExist:
pass
return super().create(validated_data)
class UpdateModerationReportSerializer(serializers.ModelSerializer):
"""Serializer for updating moderation reports."""
class Meta:
model = ModerationReport
fields = [
"status",
"priority",
"assigned_moderator",
"resolution_notes",
"resolution_action",
]
def validate_status(self, value):
"""Validate status transitions."""
if self.instance and self.instance.status == "RESOLVED":
if value != "RESOLVED":
raise serializers.ValidationError(
"Cannot change status of resolved report"
)
return value
def update(self, instance, validated_data):
"""Update moderation report with automatic timestamps."""
if "status" in validated_data and validated_data["status"] == "RESOLVED":
validated_data["resolved_at"] = timezone.now()
return super().update(instance, validated_data)
# ============================================================================
# Moderation Queue Serializers
# ============================================================================
class ModerationQueueSerializer(serializers.ModelSerializer):
"""Full moderation queue item serializer."""
assigned_to = UserBasicSerializer(read_only=True)
related_report = ModerationReportSerializer(read_only=True)
content_type = ContentTypeSerializer(read_only=True)
# Computed fields
is_overdue = serializers.SerializerMethodField()
time_in_queue = serializers.SerializerMethodField()
estimated_completion = serializers.SerializerMethodField()
class Meta:
model = ModerationQueue
fields = [
"id",
"item_type",
"status",
"priority",
"title",
"description",
"entity_type",
"entity_id",
"entity_preview",
"flagged_by",
"assigned_at",
"estimated_review_time",
"created_at",
"updated_at",
"tags",
"assigned_to",
"related_report",
"content_type",
"is_overdue",
"time_in_queue",
"estimated_completion",
]
read_only_fields = [
"id",
"created_at",
"updated_at",
"content_type",
"is_overdue",
"time_in_queue",
"estimated_completion",
]
def get_is_overdue(self, obj) -> bool:
"""Check if queue item is overdue."""
if obj.status == "COMPLETED":
return False
if obj.assigned_at:
time_assigned = (timezone.now() - obj.assigned_at).total_seconds() / 60
return time_assigned > obj.estimated_review_time
# If not assigned, check time in queue
time_in_queue = (timezone.now() - obj.created_at).total_seconds() / 60
return time_in_queue > (obj.estimated_review_time * 2)
def get_time_in_queue(self, obj) -> int:
"""Minutes since item was created."""
return int((timezone.now() - obj.created_at).total_seconds() / 60)
def get_estimated_completion(self, obj) -> str:
"""Estimated completion time."""
if obj.assigned_at:
completion_time = obj.assigned_at + timedelta(
minutes=obj.estimated_review_time
)
else:
completion_time = timezone.now() + timedelta(
minutes=obj.estimated_review_time
)
return completion_time.isoformat()
class AssignQueueItemSerializer(serializers.Serializer):
"""Serializer for assigning queue items to moderators."""
moderator_id = serializers.IntegerField()
def validate_moderator_id(self, value):
"""Validate that the moderator exists and has appropriate permissions."""
try:
user = User.objects.get(id=value)
user_role = getattr(user, "role", "USER")
if user_role not in ["MODERATOR", "ADMIN", "SUPERUSER"]:
raise serializers.ValidationError(
"User must be a moderator, admin, or superuser"
)
return value
except User.DoesNotExist:
raise serializers.ValidationError("Moderator not found")
class CompleteQueueItemSerializer(serializers.Serializer):
"""Serializer for completing queue items."""
action = serializers.ChoiceField(
choices=[
("NO_ACTION", "No Action Required"),
("CONTENT_REMOVED", "Content Removed"),
("CONTENT_EDITED", "Content Edited"),
("USER_WARNING", "User Warning Issued"),
("USER_SUSPENDED", "User Suspended"),
("USER_BANNED", "User Banned"),
]
)
notes = serializers.CharField(required=False, allow_blank=True)
def validate(self, attrs):
"""Validate completion data."""
action = attrs["action"]
notes = attrs.get("notes", "")
# Require notes for certain actions
if action in ["USER_WARNING", "USER_SUSPENDED", "USER_BANNED"] and not notes:
raise serializers.ValidationError(
{"notes": f"Notes are required for action: {action}"}
)
return attrs
# ============================================================================
# Moderation Action Serializers
# ============================================================================
class ModerationActionSerializer(serializers.ModelSerializer):
"""Full moderation action serializer."""
moderator = UserBasicSerializer(read_only=True)
target_user = UserBasicSerializer(read_only=True)
related_report = ModerationReportSerializer(read_only=True)
# Computed fields
is_expired = serializers.SerializerMethodField()
time_remaining = serializers.SerializerMethodField()
action_type_display = serializers.CharField(
source="get_action_type_display", read_only=True
)
class Meta:
model = ModerationAction
fields = [
"id",
"action_type",
"action_type_display",
"reason",
"details",
"duration_hours",
"created_at",
"expires_at",
"is_active",
"moderator",
"target_user",
"related_report",
"updated_at",
"is_expired",
"time_remaining",
]
read_only_fields = [
"id",
"created_at",
"updated_at",
"moderator",
"target_user",
"related_report",
"is_expired",
"time_remaining",
"action_type_display",
]
def get_is_expired(self, obj) -> bool:
"""Check if action has expired."""
if not obj.expires_at:
return False
return timezone.now() > obj.expires_at
def get_time_remaining(self, obj) -> str | None:
"""Time remaining until expiration."""
if not obj.expires_at or not obj.is_active:
return None
now = timezone.now()
if now >= obj.expires_at:
return "Expired"
diff = obj.expires_at - now
if diff.days > 0:
return f"{diff.days} days"
elif diff.seconds > 3600:
hours = diff.seconds // 3600
return f"{hours} hours"
else:
minutes = diff.seconds // 60
return f"{minutes} minutes"
class CreateModerationActionSerializer(serializers.ModelSerializer):
"""Serializer for creating moderation actions."""
target_user_id = serializers.IntegerField()
related_report_id = serializers.IntegerField(required=False)
class Meta:
model = ModerationAction
fields = [
"action_type",
"reason",
"details",
"duration_hours",
"target_user_id",
"related_report_id",
]
def validate_target_user_id(self, value):
"""Validate target user exists."""
try:
User.objects.get(id=value)
return value
except User.DoesNotExist:
raise serializers.ValidationError("Target user not found")
def validate_related_report_id(self, value):
"""Validate related report exists."""
if value:
try:
ModerationReport.objects.get(id=value)
return value
except ModerationReport.DoesNotExist:
raise serializers.ValidationError("Related report not found")
return value
def validate(self, attrs):
"""Validate action data."""
action_type = attrs["action_type"]
duration_hours = attrs.get("duration_hours")
# Validate duration for temporary actions
temporary_actions = ["USER_SUSPENSION", "CONTENT_RESTRICTION"]
if action_type in temporary_actions and not duration_hours:
raise serializers.ValidationError(
{"duration_hours": f"Duration is required for {action_type}"}
)
# Validate duration range
if duration_hours and (
duration_hours < 1 or duration_hours > 8760
): # 1 hour to 1 year
raise serializers.ValidationError(
{"duration_hours": "Duration must be between 1 and 8760 hours (1 year)"}
)
return attrs
def create(self, validated_data):
"""Create moderation action with automatic fields."""
target_user_id = validated_data.pop("target_user_id")
related_report_id = validated_data.pop("related_report_id", None)
validated_data["moderator"] = self.context["request"].user
validated_data["target_user_id"] = target_user_id
validated_data["is_active"] = True
if related_report_id:
validated_data["related_report_id"] = related_report_id
# Set expiration time for temporary actions
if validated_data.get("duration_hours"):
validated_data["expires_at"] = timezone.now() + timedelta(
hours=validated_data["duration_hours"]
)
return super().create(validated_data)
# ============================================================================
# Bulk Operation Serializers
# ============================================================================
class BulkOperationSerializer(serializers.ModelSerializer):
"""Full bulk operation serializer."""
created_by = UserBasicSerializer(read_only=True)
# Computed fields
progress_percentage = serializers.SerializerMethodField()
estimated_completion = serializers.SerializerMethodField()
operation_type_display = serializers.CharField(
source="get_operation_type_display", read_only=True
)
status_display = serializers.CharField(source="get_status_display", read_only=True)
class Meta:
model = BulkOperation
fields = [
"id",
"operation_type",
"operation_type_display",
"status",
"status_display",
"priority",
"parameters",
"results",
"total_items",
"processed_items",
"failed_items",
"created_at",
"started_at",
"completed_at",
"estimated_duration_minutes",
"can_cancel",
"description",
"schedule_for",
"created_by",
"updated_at",
"progress_percentage",
"estimated_completion",
]
read_only_fields = [
"id",
"created_at",
"updated_at",
"created_by",
"progress_percentage",
"estimated_completion",
"operation_type_display",
"status_display",
]
def get_progress_percentage(self, obj) -> float:
"""Calculate progress percentage."""
if obj.total_items == 0:
return 0.0
return round((obj.processed_items / obj.total_items) * 100, 2)
def get_estimated_completion(self, obj) -> str | None:
"""Estimate completion time."""
if obj.status == "COMPLETED":
return obj.completed_at.isoformat() if obj.completed_at else None
if obj.status == "RUNNING" and obj.started_at:
# Calculate based on current progress
if obj.processed_items > 0:
elapsed_minutes = (timezone.now() - obj.started_at).total_seconds() / 60
rate = obj.processed_items / elapsed_minutes
remaining_items = obj.total_items - obj.processed_items
remaining_minutes = (
remaining_items / rate
if rate > 0
else obj.estimated_duration_minutes
)
completion_time = timezone.now() + timedelta(minutes=remaining_minutes)
return completion_time.isoformat()
# Use scheduled time or estimated duration
if obj.schedule_for:
return obj.schedule_for.isoformat()
elif obj.estimated_duration_minutes:
completion_time = timezone.now() + timedelta(
minutes=obj.estimated_duration_minutes
)
return completion_time.isoformat()
return None
class CreateBulkOperationSerializer(serializers.ModelSerializer):
"""Serializer for creating bulk operations."""
class Meta:
model = BulkOperation
fields = [
"operation_type",
"priority",
"parameters",
"description",
"schedule_for",
"estimated_duration_minutes",
]
def validate_parameters(self, value):
"""Validate operation parameters."""
if not isinstance(value, dict):
raise serializers.ValidationError("Parameters must be a JSON object")
operation_type = getattr(self, "initial_data", {}).get("operation_type")
# Validate required parameters by operation type
required_params = {
"UPDATE_PARKS": ["park_ids", "updates"],
"UPDATE_RIDES": ["ride_ids", "updates"],
"IMPORT_DATA": ["data_type", "source"],
"EXPORT_DATA": ["data_type", "format"],
"MODERATE_CONTENT": ["content_type", "action"],
"USER_ACTIONS": ["user_ids", "action"],
}
if operation_type in required_params:
for param in required_params[operation_type]:
if param not in value:
raise serializers.ValidationError(
f'Parameter "{param}" is required for {operation_type}'
)
return value
def create(self, validated_data):
"""Create bulk operation with automatic fields."""
validated_data["created_by"] = self.context["request"].user
validated_data["status"] = "PENDING"
validated_data["total_items"] = 0
validated_data["processed_items"] = 0
validated_data["failed_items"] = 0
validated_data["can_cancel"] = True
# Generate unique ID
import uuid
validated_data["id"] = str(uuid.uuid4())[:50]
return super().create(validated_data)
# ============================================================================
# Statistics and Summary Serializers
# ============================================================================
class ModerationStatsSerializer(serializers.Serializer):
"""Serializer for moderation statistics."""
# Report stats
total_reports = serializers.IntegerField()
pending_reports = serializers.IntegerField()
resolved_reports = serializers.IntegerField()
overdue_reports = serializers.IntegerField()
# Queue stats
queue_size = serializers.IntegerField()
assigned_items = serializers.IntegerField()
unassigned_items = serializers.IntegerField()
# Action stats
total_actions = serializers.IntegerField()
active_actions = serializers.IntegerField()
expired_actions = serializers.IntegerField()
# Bulk operation stats
running_operations = serializers.IntegerField()
completed_operations = serializers.IntegerField()
failed_operations = serializers.IntegerField()
# Performance metrics
average_resolution_time_hours = serializers.FloatField()
reports_by_priority = serializers.DictField()
reports_by_type = serializers.DictField()
class UserModerationProfileSerializer(serializers.Serializer):
"""Serializer for user moderation profile."""
user = UserBasicSerializer()
# Report history
reports_made = serializers.IntegerField()
reports_against = serializers.IntegerField()
# Action history
warnings_received = serializers.IntegerField()
suspensions_received = serializers.IntegerField()
active_restrictions = serializers.IntegerField()
# Risk assessment
risk_level = serializers.ChoiceField(
choices=[
("LOW", "Low Risk"),
("MEDIUM", "Medium Risk"),
("HIGH", "High Risk"),
("CRITICAL", "Critical Risk"),
]
)
risk_factors = serializers.ListField(child=serializers.CharField())
# Recent activity
recent_reports = ModerationReportSerializer(many=True)
recent_actions = ModerationActionSerializer(many=True)
# Account status
account_status = serializers.CharField()
last_violation_date = serializers.DateTimeField(allow_null=True)
next_review_date = serializers.DateTimeField(allow_null=True)

View File

@@ -1,642 +0,0 @@
"""
Services for moderation functionality.
Following Django styleguide pattern for business logic encapsulation.
"""
from typing import Optional, Dict, Any, Union
from django.db import transaction
from django.utils import timezone
from django.db.models import QuerySet
from apps.accounts.models import User
from .models import EditSubmission, PhotoSubmission, ModerationQueue
class ModerationService:
"""Service for handling content moderation workflows."""
@staticmethod
def approve_submission(
*, submission_id: int, moderator: User, notes: Optional[str] = None
) -> Union[object, None]:
"""
Approve a content submission and apply changes.
Args:
submission_id: ID of the submission to approve
moderator: User performing the approval
notes: Optional notes about the approval
Returns:
The created/updated object or None if approval failed
Raises:
EditSubmission.DoesNotExist: If submission doesn't exist
ValidationError: If submission data is invalid
ValueError: If submission cannot be processed
"""
with transaction.atomic():
submission = EditSubmission.objects.select_for_update().get(
id=submission_id
)
if submission.status != "PENDING":
raise ValueError(f"Submission {submission_id} is not pending approval")
try:
# Call the model's approve method which handles the business
# logic
obj = submission.approve(moderator)
# Add moderator notes if provided
if notes:
if submission.notes:
submission.notes += f"\n[Moderator]: {notes}"
else:
submission.notes = f"[Moderator]: {notes}"
submission.save()
return obj
except Exception as e:
# Mark as rejected on any error
submission.status = "REJECTED"
submission.handled_by = moderator
submission.handled_at = timezone.now()
submission.notes = f"Approval failed: {str(e)}"
submission.save()
raise
@staticmethod
def reject_submission(
*, submission_id: int, moderator: User, reason: str
) -> EditSubmission:
"""
Reject a content submission.
Args:
submission_id: ID of the submission to reject
moderator: User performing the rejection
reason: Reason for rejection
Returns:
Updated submission object
Raises:
EditSubmission.DoesNotExist: If submission doesn't exist
ValueError: If submission cannot be rejected
"""
with transaction.atomic():
submission = EditSubmission.objects.select_for_update().get(
id=submission_id
)
if submission.status != "PENDING":
raise ValueError(f"Submission {submission_id} is not pending review")
submission.status = "REJECTED"
submission.handled_by = moderator
submission.handled_at = timezone.now()
submission.notes = f"Rejected: {reason}"
# Call full_clean before saving - CRITICAL STYLEGUIDE FIX
submission.full_clean()
submission.save()
return submission
@staticmethod
def create_edit_submission(
*,
content_object: object,
changes: Dict[str, Any],
submitter: User,
submission_type: str = "UPDATE",
notes: Optional[str] = None,
) -> EditSubmission:
"""
Create a new edit submission for moderation.
Args:
content_object: The object being edited
changes: Dictionary of field changes
submitter: User submitting the changes
submission_type: Type of submission ("CREATE" or "UPDATE")
notes: Optional notes about the submission
Returns:
Created EditSubmission object
Raises:
ValidationError: If submission data is invalid
"""
submission = EditSubmission(
content_object=content_object,
changes=changes,
user=submitter,
submission_type=submission_type,
reason=notes or "",
)
# Call full_clean before saving - CRITICAL STYLEGUIDE FIX
submission.full_clean()
submission.save()
return submission
@staticmethod
def update_submission_changes(
*,
submission_id: int,
moderator_changes: Dict[str, Any],
moderator: User,
) -> EditSubmission:
"""
Update submission with moderator changes before approval.
Args:
submission_id: ID of the submission to update
moderator_changes: Dictionary of moderator modifications
moderator: User making the changes
Returns:
Updated submission object
Raises:
EditSubmission.DoesNotExist: If submission doesn't exist
ValueError: If submission cannot be modified
"""
with transaction.atomic():
submission = EditSubmission.objects.select_for_update().get(
id=submission_id
)
if submission.status != "PENDING":
raise ValueError(f"Submission {submission_id} is not pending review")
submission.moderator_changes = moderator_changes
# Add note about moderator changes
note = f"[Moderator changes by {moderator.username}]"
if submission.notes:
submission.notes += f"\n{note}"
else:
submission.notes = note
# Call full_clean before saving - CRITICAL STYLEGUIDE FIX
submission.full_clean()
submission.save()
return submission
@staticmethod
def get_pending_submissions_for_moderator(
*,
moderator: User,
content_type: Optional[str] = None,
limit: Optional[int] = None,
) -> QuerySet:
"""
Get pending submissions for a moderator to review.
Args:
moderator: The moderator user
content_type: Optional filter by content type
limit: Maximum number of submissions to return
Returns:
QuerySet of pending submissions
"""
from .selectors import pending_submissions_for_review
return pending_submissions_for_review(content_type=content_type, limit=limit)
@staticmethod
def get_submission_statistics(
*, days: int = 30, moderator: Optional[User] = None
) -> Dict[str, Any]:
"""
Get moderation statistics for a time period.
Args:
days: Number of days to analyze
moderator: Optional filter by specific moderator
Returns:
Dictionary containing moderation statistics
"""
from .selectors import moderation_statistics_summary
return moderation_statistics_summary(days=days, moderator=moderator)
@staticmethod
def _is_moderator_or_above(user: User) -> bool:
"""
Check if user has moderator privileges or above.
Args:
user: User to check
Returns:
True if user is MODERATOR, ADMIN, or SUPERUSER
"""
return user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']
@staticmethod
def create_edit_submission_with_queue(
*,
content_object: Optional[object],
changes: Dict[str, Any],
submitter: User,
submission_type: str = "EDIT",
reason: Optional[str] = None,
source: Optional[str] = None,
) -> Dict[str, Any]:
"""
Create an edit submission with automatic queue routing.
For moderators and above: Creates submission and auto-approves
For regular users: Creates submission and adds to moderation queue
Args:
content_object: The object being edited (None for CREATE)
changes: Dictionary of field changes
submitter: User submitting the changes
submission_type: Type of submission ("CREATE" or "EDIT")
reason: Reason for the submission
source: Source of information
Returns:
Dictionary with submission info and queue status
"""
with transaction.atomic():
# Create the submission
submission = EditSubmission(
content_object=content_object,
changes=changes,
user=submitter,
submission_type=submission_type,
reason=reason or "",
source=source or "",
)
submission.full_clean()
submission.save()
# Check if user is moderator or above
if ModerationService._is_moderator_or_above(submitter):
# Auto-approve for moderators
try:
created_object = submission.approve(submitter)
return {
'submission': submission,
'status': 'auto_approved',
'created_object': created_object,
'queue_item': None,
'message': 'Submission auto-approved for moderator'
}
except Exception as e:
return {
'submission': submission,
'status': 'failed',
'created_object': None,
'queue_item': None,
'message': f'Auto-approval failed: {str(e)}'
}
else:
# Create queue item for regular users
queue_item = ModerationService._create_queue_item_for_submission(
submission=submission,
submitter=submitter
)
return {
'submission': submission,
'status': 'queued',
'created_object': None,
'queue_item': queue_item,
'message': 'Submission added to moderation queue'
}
@staticmethod
def create_photo_submission_with_queue(
*,
content_object: object,
photo,
caption: str = "",
date_taken=None,
submitter: User,
) -> Dict[str, Any]:
"""
Create a photo submission with automatic queue routing.
For moderators and above: Creates submission and auto-approves
For regular users: Creates submission and adds to moderation queue
Args:
content_object: The object the photo is for
photo: The photo file
caption: Photo caption
date_taken: Date the photo was taken
submitter: User submitting the photo
Returns:
Dictionary with submission info and queue status
"""
with transaction.atomic():
# Create the photo submission
submission = PhotoSubmission(
content_object=content_object,
photo=photo,
caption=caption,
date_taken=date_taken,
user=submitter,
)
submission.full_clean()
submission.save()
# Check if user is moderator or above
if ModerationService._is_moderator_or_above(submitter):
# Auto-approve for moderators
try:
submission.auto_approve()
return {
'submission': submission,
'status': 'auto_approved',
'queue_item': None,
'message': 'Photo submission auto-approved for moderator'
}
except Exception as e:
return {
'submission': submission,
'status': 'failed',
'queue_item': None,
'message': f'Auto-approval failed: {str(e)}'
}
else:
# Create queue item for regular users
queue_item = ModerationService._create_queue_item_for_photo_submission(
submission=submission,
submitter=submitter
)
return {
'submission': submission,
'status': 'queued',
'queue_item': queue_item,
'message': 'Photo submission added to moderation queue'
}
@staticmethod
def _create_queue_item_for_submission(
*, submission: EditSubmission, submitter: User
) -> ModerationQueue:
"""
Create a moderation queue item for an edit submission.
Args:
submission: The edit submission
submitter: User who made the submission
Returns:
Created ModerationQueue item
"""
# Determine content type and entity info
content_type = submission.content_type
entity_type = content_type.model if content_type else "unknown"
entity_id = submission.object_id
# Create preview data
entity_preview = {
'submission_type': submission.submission_type,
'changes_count': len(submission.changes) if submission.changes else 0,
'reason': submission.reason[:100] if submission.reason else "",
}
if submission.content_object:
entity_preview['object_name'] = str(submission.content_object)
# Determine title and description
action = "creation" if submission.submission_type == "CREATE" else "edit"
title = f"{entity_type.title()} {action} by {submitter.username}"
description = f"Review {action} submission for {entity_type}"
if submission.reason:
description += f". Reason: {submission.reason}"
# Create queue item
queue_item = ModerationQueue(
item_type='CONTENT_REVIEW',
title=title,
description=description,
entity_type=entity_type,
entity_id=entity_id,
entity_preview=entity_preview,
content_type=content_type,
flagged_by=submitter,
priority='MEDIUM',
estimated_review_time=15, # 15 minutes default
tags=['edit_submission', submission.submission_type.lower()],
)
queue_item.full_clean()
queue_item.save()
return queue_item
@staticmethod
def _create_queue_item_for_photo_submission(
*, submission: PhotoSubmission, submitter: User
) -> ModerationQueue:
"""
Create a moderation queue item for a photo submission.
Args:
submission: The photo submission
submitter: User who made the submission
Returns:
Created ModerationQueue item
"""
# Determine content type and entity info
content_type = submission.content_type
entity_type = content_type.model if content_type else "unknown"
entity_id = submission.object_id
# Create preview data
entity_preview = {
'caption': submission.caption,
'date_taken': submission.date_taken.isoformat() if submission.date_taken else None,
'photo_url': submission.photo.url if submission.photo else None,
}
if submission.content_object:
entity_preview['object_name'] = str(submission.content_object)
# Create title and description
title = f"Photo submission for {entity_type} by {submitter.username}"
description = f"Review photo submission for {entity_type}"
if submission.caption:
description += f". Caption: {submission.caption}"
# Create queue item
queue_item = ModerationQueue(
item_type='CONTENT_REVIEW',
title=title,
description=description,
entity_type=entity_type,
entity_id=entity_id,
entity_preview=entity_preview,
content_type=content_type,
flagged_by=submitter,
priority='LOW', # Photos typically lower priority
estimated_review_time=5, # 5 minutes default for photos
tags=['photo_submission'],
)
queue_item.full_clean()
queue_item.save()
return queue_item
@staticmethod
def process_queue_item(
*, queue_item_id: int, moderator: User, action: str, notes: Optional[str] = None
) -> Dict[str, Any]:
"""
Process a moderation queue item (approve, reject, etc.).
Args:
queue_item_id: ID of the queue item to process
moderator: User processing the item
action: Action to take ('approve', 'reject', 'escalate')
notes: Optional notes about the action
Returns:
Dictionary with processing results
"""
with transaction.atomic():
queue_item = ModerationQueue.objects.select_for_update().get(
id=queue_item_id
)
if queue_item.status != 'PENDING':
raise ValueError(f"Queue item {queue_item_id} is not pending")
# Find related submission
if 'edit_submission' in queue_item.tags:
# Find EditSubmission
submissions = EditSubmission.objects.filter(
user=queue_item.flagged_by,
content_type=queue_item.content_type,
object_id=queue_item.entity_id,
status='PENDING'
).order_by('-created_at')
if not submissions.exists():
raise ValueError(
"No pending edit submission found for this queue item")
submission = submissions.first()
if action == 'approve':
try:
created_object = submission.approve(moderator)
queue_item.status = 'COMPLETED'
result = {
'status': 'approved',
'created_object': created_object,
'message': 'Submission approved successfully'
}
except Exception as e:
queue_item.status = 'COMPLETED'
result = {
'status': 'failed',
'created_object': None,
'message': f'Approval failed: {str(e)}'
}
elif action == 'reject':
submission.reject(moderator, notes or "Rejected by moderator")
queue_item.status = 'COMPLETED'
result = {
'status': 'rejected',
'created_object': None,
'message': 'Submission rejected'
}
elif action == 'escalate':
submission.escalate(moderator, notes or "Escalated for review")
queue_item.priority = 'HIGH'
queue_item.status = 'PENDING' # Keep in queue but escalated
result = {
'status': 'escalated',
'created_object': None,
'message': 'Submission escalated'
}
else:
raise ValueError(f"Unknown action: {action}")
elif 'photo_submission' in queue_item.tags:
# Find PhotoSubmission
submissions = PhotoSubmission.objects.filter(
user=queue_item.flagged_by,
content_type=queue_item.content_type,
object_id=queue_item.entity_id,
status='PENDING'
).order_by('-created_at')
if not submissions.exists():
raise ValueError(
"No pending photo submission found for this queue item")
submission = submissions.first()
if action == 'approve':
try:
submission.approve(moderator, notes or "")
queue_item.status = 'COMPLETED'
result = {
'status': 'approved',
'created_object': None,
'message': 'Photo submission approved successfully'
}
except Exception as e:
queue_item.status = 'COMPLETED'
result = {
'status': 'failed',
'created_object': None,
'message': f'Photo approval failed: {str(e)}'
}
elif action == 'reject':
submission.reject(moderator, notes or "Rejected by moderator")
queue_item.status = 'COMPLETED'
result = {
'status': 'rejected',
'created_object': None,
'message': 'Photo submission rejected'
}
elif action == 'escalate':
submission.escalate(moderator, notes or "Escalated for review")
queue_item.priority = 'HIGH'
queue_item.status = 'PENDING' # Keep in queue but escalated
result = {
'status': 'escalated',
'created_object': None,
'message': 'Photo submission escalated'
}
else:
raise ValueError(f"Unknown action: {action}")
else:
raise ValueError("Unknown queue item type")
# Update queue item
queue_item.assigned_to = moderator
queue_item.assigned_at = timezone.now()
if notes:
queue_item.description += f"\n\nModerator notes: {notes}"
queue_item.full_clean()
queue_item.save()
result['queue_item'] = queue_item
return result

View File

@@ -1,87 +0,0 @@
"""
Moderation URLs
This module defines URL patterns for the moderation API endpoints.
All endpoints are nested under /api/moderation/ and provide comprehensive
moderation functionality including reports, queue management, actions, and bulk operations.
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
ModerationReportViewSet,
ModerationQueueViewSet,
ModerationActionViewSet,
BulkOperationViewSet,
UserModerationViewSet,
)
# Create router and register viewsets
router = DefaultRouter()
router.register(r"reports", ModerationReportViewSet, basename="moderation-reports")
router.register(r"queue", ModerationQueueViewSet, basename="moderation-queue")
router.register(r"actions", ModerationActionViewSet, basename="moderation-actions")
router.register(r"bulk-operations", BulkOperationViewSet, basename="bulk-operations")
router.register(r"users", UserModerationViewSet, basename="user-moderation")
app_name = "moderation"
urlpatterns = [
# Include all router URLs
path("", include(router.urls)),
]
# URL patterns generated by the router:
#
# Moderation Reports:
# GET /api/moderation/reports/ - List all reports
# POST /api/moderation/reports/ - Create new report
# GET /api/moderation/reports/{id}/ - Get specific report
# PUT /api/moderation/reports/{id}/ - Update report
# PATCH /api/moderation/reports/{id}/ - Partial update report
# DELETE /api/moderation/reports/{id}/ - Delete report
# POST /api/moderation/reports/{id}/assign/ - Assign report to moderator
# POST /api/moderation/reports/{id}/resolve/ - Resolve report
# GET /api/moderation/reports/stats/ - Get report statistics
#
# Moderation Queue:
# GET /api/moderation/queue/ - List queue items
# POST /api/moderation/queue/ - Create queue item
# GET /api/moderation/queue/{id}/ - Get specific queue item
# PUT /api/moderation/queue/{id}/ - Update queue item
# PATCH /api/moderation/queue/{id}/ - Partial update queue item
# DELETE /api/moderation/queue/{id}/ - Delete queue item
# POST /api/moderation/queue/{id}/assign/ - Assign queue item
# POST /api/moderation/queue/{id}/unassign/ - Unassign queue item
# POST /api/moderation/queue/{id}/complete/ - Complete queue item
# GET /api/moderation/queue/my_queue/ - Get current user's queue items
#
# Moderation Actions:
# GET /api/moderation/actions/ - List all actions
# POST /api/moderation/actions/ - Create new action
# GET /api/moderation/actions/{id}/ - Get specific action
# PUT /api/moderation/actions/{id}/ - Update action
# PATCH /api/moderation/actions/{id}/ - Partial update action
# DELETE /api/moderation/actions/{id}/ - Delete action
# POST /api/moderation/actions/{id}/deactivate/ - Deactivate action
# GET /api/moderation/actions/active/ - Get active actions
# GET /api/moderation/actions/expired/ - Get expired actions
#
# Bulk Operations:
# GET /api/moderation/bulk-operations/ - List bulk operations
# POST /api/moderation/bulk-operations/ - Create bulk operation
# GET /api/moderation/bulk-operations/{id}/ - Get specific operation
# PUT /api/moderation/bulk-operations/{id}/ - Update operation
# PATCH /api/moderation/bulk-operations/{id}/ - Partial update operation
# DELETE /api/moderation/bulk-operations/{id}/ - Delete operation
# POST /api/moderation/bulk-operations/{id}/cancel/ - Cancel operation
# POST /api/moderation/bulk-operations/{id}/retry/ - Retry failed operation
# GET /api/moderation/bulk-operations/{id}/logs/ - Get operation logs
# GET /api/moderation/bulk-operations/running/ - Get running operations
#
# User Moderation:
# GET /api/moderation/users/{id}/ - Get user moderation profile
# POST /api/moderation/users/{id}/moderate/ - Take action against user
# GET /api/moderation/users/search/ - Search users for moderation
# GET /api/moderation/users/stats/ - Get user moderation statistics

View File

@@ -1,737 +0,0 @@
"""
Moderation API Views
This module contains DRF viewsets for the moderation system, including:
- ModerationReport views for content reporting
- ModerationQueue views for moderation workflow
- ModerationAction views for tracking moderation actions
- BulkOperation views for administrative bulk operations
All views include comprehensive permissions, filtering, and pagination.
"""
from rest_framework import viewsets, status, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.filters import SearchFilter, OrderingFilter
from django_filters.rest_framework import DjangoFilterBackend
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.db.models import Q, Count
from datetime import timedelta
from .models import (
ModerationReport,
ModerationQueue,
ModerationAction,
BulkOperation,
)
from .serializers import (
ModerationReportSerializer,
CreateModerationReportSerializer,
UpdateModerationReportSerializer,
ModerationQueueSerializer,
AssignQueueItemSerializer,
CompleteQueueItemSerializer,
ModerationActionSerializer,
CreateModerationActionSerializer,
BulkOperationSerializer,
CreateBulkOperationSerializer,
UserModerationProfileSerializer,
)
from .filters import (
ModerationReportFilter,
ModerationQueueFilter,
ModerationActionFilter,
BulkOperationFilter,
)
from .permissions import (
IsModeratorOrAdmin,
IsAdminOrSuperuser,
CanViewModerationData,
)
User = get_user_model()
# ============================================================================
# Moderation Report ViewSet
# ============================================================================
class ModerationReportViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing moderation reports.
Provides CRUD operations for moderation reports with comprehensive
filtering, search, and permission controls.
"""
queryset = ModerationReport.objects.select_related(
"reported_by", "assigned_moderator", "content_type"
).all()
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = ModerationReportFilter
search_fields = ["reason", "description", "resolution_notes"]
ordering_fields = ["created_at", "updated_at", "priority", "status"]
ordering = ["-created_at"]
def get_serializer_class(self):
"""Return appropriate serializer based on action."""
if self.action == "create":
return CreateModerationReportSerializer
elif self.action in ["update", "partial_update"]:
return UpdateModerationReportSerializer
return ModerationReportSerializer
def get_permissions(self):
"""Return appropriate permissions based on action."""
if self.action == "create":
# Any authenticated user can create reports
permission_classes = [permissions.IsAuthenticated]
elif self.action in ["list", "retrieve"]:
# Moderators and above can view reports
permission_classes = [CanViewModerationData]
else:
# Only moderators and above can modify reports
permission_classes = [IsModeratorOrAdmin]
return [permission() for permission in permission_classes]
def get_queryset(self):
"""Filter queryset based on user permissions."""
queryset = super().get_queryset()
# Regular users can only see their own reports
if not self.request.user.is_authenticated:
return queryset.none()
user_role = getattr(self.request.user, "role", "USER")
if user_role == "USER":
queryset = queryset.filter(reported_by=self.request.user)
return queryset
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def assign(self, request, pk=None):
"""Assign a report to a moderator."""
report = self.get_object()
moderator_id = request.data.get("moderator_id")
try:
moderator = User.objects.get(id=moderator_id)
moderator_role = getattr(moderator, "role", "USER")
if moderator_role not in ["MODERATOR", "ADMIN", "SUPERUSER"]:
return Response(
{"error": "User must be a moderator, admin, or superuser"},
status=status.HTTP_400_BAD_REQUEST,
)
report.assigned_moderator = moderator
report.status = "UNDER_REVIEW"
report.save()
serializer = self.get_serializer(report)
return Response(serializer.data)
except User.DoesNotExist:
return Response(
{"error": "Moderator not found"}, status=status.HTTP_404_NOT_FOUND
)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def resolve(self, request, pk=None):
"""Resolve a moderation report."""
report = self.get_object()
resolution_action = request.data.get("resolution_action")
resolution_notes = request.data.get("resolution_notes", "")
if not resolution_action:
return Response(
{"error": "resolution_action is required"},
status=status.HTTP_400_BAD_REQUEST,
)
report.status = "RESOLVED"
report.resolution_action = resolution_action
report.resolution_notes = resolution_notes
report.resolved_at = timezone.now()
report.save()
serializer = self.get_serializer(report)
return Response(serializer.data)
@action(detail=False, methods=["get"], permission_classes=[CanViewModerationData])
def stats(self, request):
"""Get moderation report statistics."""
queryset = self.get_queryset()
# Basic counts
total_reports = queryset.count()
pending_reports = queryset.filter(status="PENDING").count()
resolved_reports = queryset.filter(status="RESOLVED").count()
# Overdue reports (based on priority SLA)
now = timezone.now()
overdue_reports = 0
for report in queryset.filter(status__in=["PENDING", "UNDER_REVIEW"]):
sla_hours = {"URGENT": 2, "HIGH": 8, "MEDIUM": 24, "LOW": 72}
hours_since_created = (now - report.created_at).total_seconds() / 3600
if report.priority in sla_hours:
threshold = sla_hours[report.priority]
else:
raise ValueError(f"Unknown priority level: {report.priority}")
if hours_since_created > threshold:
overdue_reports += 1
# Reports by priority and type
reports_by_priority = dict(
queryset.values_list("priority").annotate(count=Count("id"))
)
reports_by_type = dict(
queryset.values_list("report_type").annotate(count=Count("id"))
)
# Average resolution time
resolved_queryset = queryset.filter(
status="RESOLVED", resolved_at__isnull=False
)
avg_resolution_time = 0
if resolved_queryset.exists():
total_time = sum(
[
(report.resolved_at - report.created_at).total_seconds() / 3600
for report in resolved_queryset
if report.resolved_at
]
)
avg_resolution_time = total_time / resolved_queryset.count()
stats_data = {
"total_reports": total_reports,
"pending_reports": pending_reports,
"resolved_reports": resolved_reports,
"overdue_reports": overdue_reports,
"reports_by_priority": reports_by_priority,
"reports_by_type": reports_by_type,
"average_resolution_time_hours": round(avg_resolution_time, 2),
}
return Response(stats_data)
# ============================================================================
# Moderation Queue ViewSet
# ============================================================================
class ModerationQueueViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing moderation queue items.
Provides workflow management for moderation tasks with assignment,
completion, and progress tracking.
"""
queryset = ModerationQueue.objects.select_related(
"assigned_to", "related_report", "content_type"
).all()
serializer_class = ModerationQueueSerializer
permission_classes = [CanViewModerationData]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = ModerationQueueFilter
search_fields = ["title", "description"]
ordering_fields = ["created_at", "updated_at", "priority", "status"]
ordering = ["-created_at"]
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def assign(self, request, pk=None):
"""Assign a queue item to a moderator."""
queue_item = self.get_object()
serializer = AssignQueueItemSerializer(data=request.data)
if serializer.is_valid():
moderator_id = serializer.validated_data["moderator_id"]
moderator = User.objects.get(id=moderator_id)
queue_item.assigned_to = moderator
queue_item.assigned_at = timezone.now()
queue_item.status = "IN_PROGRESS"
queue_item.save()
response_serializer = self.get_serializer(queue_item)
return Response(response_serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def unassign(self, request, pk=None):
"""Unassign a queue item."""
queue_item = self.get_object()
queue_item.assigned_to = None
queue_item.assigned_at = None
queue_item.status = "PENDING"
queue_item.save()
serializer = self.get_serializer(queue_item)
return Response(serializer.data)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def complete(self, request, pk=None):
"""Complete a queue item."""
queue_item = self.get_object()
serializer = CompleteQueueItemSerializer(data=request.data)
if serializer.is_valid():
action_taken = serializer.validated_data["action"]
notes = serializer.validated_data.get("notes", "")
queue_item.status = "COMPLETED"
queue_item.save()
# Create moderation action if needed
if action_taken != "NO_ACTION" and queue_item.related_report:
ModerationAction.objects.create(
action_type=action_taken,
reason=f"Queue item completion: {action_taken}",
details=notes,
moderator=request.user,
target_user=queue_item.related_report.reported_by,
related_report=queue_item.related_report,
is_active=True,
)
response_serializer = self.get_serializer(queue_item)
return Response(response_serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@action(detail=False, methods=["get"], permission_classes=[CanViewModerationData])
def my_queue(self, request):
"""Get queue items assigned to the current user."""
queryset = self.get_queryset().filter(assigned_to=request.user)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
# ============================================================================
# Moderation Action ViewSet
# ============================================================================
class ModerationActionViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing moderation actions.
Tracks actions taken against users and content with expiration
and status management.
"""
queryset = ModerationAction.objects.select_related(
"moderator", "target_user", "related_report"
).all()
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = ModerationActionFilter
search_fields = ["reason", "details"]
ordering_fields = ["created_at", "expires_at", "action_type"]
ordering = ["-created_at"]
def get_serializer_class(self):
"""Return appropriate serializer based on action."""
if self.action == "create":
return CreateModerationActionSerializer
return ModerationActionSerializer
def get_permissions(self):
"""Return appropriate permissions based on action."""
if self.action == "create":
permission_classes = [IsModeratorOrAdmin]
else:
permission_classes = [CanViewModerationData]
return [permission() for permission in permission_classes]
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def deactivate(self, request, pk=None):
"""Deactivate a moderation action."""
action_obj = self.get_object()
action_obj.is_active = False
action_obj.save()
serializer = self.get_serializer(action_obj)
return Response(serializer.data)
@action(detail=False, methods=["get"], permission_classes=[CanViewModerationData])
def active(self, request):
"""Get all active moderation actions."""
queryset = self.get_queryset().filter(
is_active=True, expires_at__gt=timezone.now()
)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=["get"], permission_classes=[CanViewModerationData])
def expired(self, request):
"""Get all expired moderation actions."""
queryset = self.get_queryset().filter(
expires_at__lte=timezone.now(), is_active=True
)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
# ============================================================================
# Bulk Operation ViewSet
# ============================================================================
class BulkOperationViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing bulk operations.
Provides administrative bulk operations with progress tracking
and cancellation support.
"""
queryset = BulkOperation.objects.select_related("created_by").all()
permission_classes = [IsAdminOrSuperuser]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = BulkOperationFilter
search_fields = ["description"]
ordering_fields = ["created_at", "started_at", "completed_at", "priority"]
ordering = ["-created_at"]
def get_serializer_class(self):
"""Return appropriate serializer based on action."""
if self.action == "create":
return CreateBulkOperationSerializer
return BulkOperationSerializer
@action(detail=True, methods=["post"])
def cancel(self, request, pk=None):
"""Cancel a bulk operation."""
operation = self.get_object()
if operation.status not in ["PENDING", "RUNNING"]:
return Response(
{"error": "Operation cannot be cancelled"},
status=status.HTTP_400_BAD_REQUEST,
)
if not operation.can_cancel:
return Response(
{"error": "Operation is not cancellable"},
status=status.HTTP_400_BAD_REQUEST,
)
operation.status = "CANCELLED"
operation.completed_at = timezone.now()
operation.save()
serializer = self.get_serializer(operation)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def retry(self, request, pk=None):
"""Retry a failed bulk operation."""
operation = self.get_object()
if operation.status != "FAILED":
return Response(
{"error": "Only failed operations can be retried"},
status=status.HTTP_400_BAD_REQUEST,
)
# Reset operation status
operation.status = "PENDING"
operation.started_at = None
operation.completed_at = None
operation.processed_items = 0
operation.failed_items = 0
operation.results = {}
operation.save()
serializer = self.get_serializer(operation)
return Response(serializer.data)
@action(detail=True, methods=["get"])
def logs(self, request, pk=None):
"""Get logs for a bulk operation."""
operation = self.get_object()
# This would typically fetch logs from a logging system
# For now, return a placeholder response
logs = {
"logs": [
{
"timestamp": operation.created_at.isoformat(),
"level": "INFO",
"message": f"Operation {operation.id} created",
"details": operation.parameters,
}
],
"count": 1,
}
return Response(logs)
@action(detail=False, methods=["get"])
def running(self, request):
"""Get all running bulk operations."""
queryset = self.get_queryset().filter(status="RUNNING")
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
# ============================================================================
# User Moderation ViewSet
# ============================================================================
class UserModerationViewSet(viewsets.ViewSet):
"""
ViewSet for user moderation operations.
Provides user-specific moderation data, statistics, and actions.
"""
permission_classes = [IsModeratorOrAdmin]
# Default serializer for schema generation
serializer_class = UserModerationProfileSerializer
def retrieve(self, request, pk=None):
"""Get moderation profile for a specific user."""
try:
user = User.objects.get(pk=pk)
except User.DoesNotExist:
return Response(
{"error": "User not found"}, status=status.HTTP_404_NOT_FOUND
)
# Gather user moderation data
reports_made = ModerationReport.objects.filter(reported_by=user).count()
reports_against = ModerationReport.objects.filter(
reported_entity_type="user", reported_entity_id=user.id
).count()
actions_against = ModerationAction.objects.filter(target_user=user)
warnings_received = actions_against.filter(action_type="WARNING").count()
suspensions_received = actions_against.filter(
action_type="USER_SUSPENSION"
).count()
active_restrictions = actions_against.filter(
is_active=True, expires_at__gt=timezone.now()
).count()
# Risk assessment (simplified)
risk_factors = []
risk_level = "LOW"
if reports_against > 5:
risk_factors.append("Multiple reports against user")
risk_level = "MEDIUM"
if suspensions_received > 0:
risk_factors.append("Previous suspensions")
risk_level = "HIGH"
if active_restrictions > 0:
risk_factors.append("Active restrictions")
risk_level = "HIGH"
# Recent activity
recent_reports = ModerationReport.objects.filter(reported_by=user).order_by(
"-created_at"
)[:5]
recent_actions = actions_against.order_by("-created_at")[:5]
# Account status
account_status = "ACTIVE"
if getattr(user, "is_banned", False):
account_status = "BANNED"
elif active_restrictions > 0:
account_status = "RESTRICTED"
last_violation = (
actions_against.filter(
action_type__in=["WARNING", "USER_SUSPENSION", "USER_BAN"]
)
.order_by("-created_at")
.first()
)
profile_data = {
"user": {
"id": user.id,
"username": user.username,
"display_name": user.get_display_name(),
"email": user.email,
"role": getattr(user, "role", "USER"),
},
"reports_made": reports_made,
"reports_against": reports_against,
"warnings_received": warnings_received,
"suspensions_received": suspensions_received,
"active_restrictions": active_restrictions,
"risk_level": risk_level,
"risk_factors": risk_factors,
"recent_reports": ModerationReportSerializer(
recent_reports, many=True
).data,
"recent_actions": ModerationActionSerializer(
recent_actions, many=True
).data,
"account_status": account_status,
"last_violation_date": (
last_violation.created_at if last_violation else None
),
"next_review_date": None, # Would be calculated based on business rules
}
return Response(profile_data)
@action(detail=True, methods=["post"])
def moderate(self, request, pk=None):
"""Take moderation action against a user."""
try:
user = User.objects.get(pk=pk)
except User.DoesNotExist:
return Response(
{"error": "User not found"}, status=status.HTTP_404_NOT_FOUND
)
serializer = CreateModerationActionSerializer(
data=request.data, context={"request": request}
)
if serializer.is_valid():
# Override target_user_id with the user from URL
validated_data = serializer.validated_data.copy()
validated_data["target_user_id"] = user.id
action = ModerationAction.objects.create(
action_type=validated_data["action_type"],
reason=validated_data["reason"],
details=validated_data["details"],
duration_hours=validated_data.get("duration_hours"),
moderator=request.user,
target_user=user,
related_report_id=validated_data.get("related_report_id"),
is_active=True,
expires_at=(
timezone.now() + timedelta(hours=validated_data["duration_hours"])
if validated_data.get("duration_hours")
else None
),
)
response_serializer = ModerationActionSerializer(action)
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@action(detail=False, methods=["get"])
def search(self, request):
"""Search users for moderation purposes."""
query = request.query_params.get("query", "")
role = request.query_params.get("role")
has_restrictions = request.query_params.get("has_restrictions")
queryset = User.objects.all()
if query:
queryset = queryset.filter(
Q(username__icontains=query) | Q(email__icontains=query)
)
if role:
queryset = queryset.filter(role=role)
if has_restrictions == "true":
active_action_users = ModerationAction.objects.filter(
is_active=True, expires_at__gt=timezone.now()
).values_list("target_user_id", flat=True)
queryset = queryset.filter(id__in=active_action_users)
# Paginate results
page = self.paginate_queryset(queryset)
if page is not None:
users_data = []
for user in page:
restriction_count = ModerationAction.objects.filter(
target_user=user, is_active=True, expires_at__gt=timezone.now()
).count()
users_data.append(
{
"id": user.id,
"username": user.username,
"display_name": user.get_display_name(),
"email": user.email,
"role": getattr(user, "role", "USER"),
"date_joined": user.date_joined,
"last_login": user.last_login,
"is_active": user.is_active,
"restriction_count": restriction_count,
"risk_level": "HIGH" if restriction_count > 0 else "LOW",
}
)
return self.get_paginated_response(users_data)
return Response([])
@action(detail=False, methods=["get"])
def stats(self, request):
"""Get overall user moderation statistics."""
total_actions = ModerationAction.objects.count()
active_actions = ModerationAction.objects.filter(
is_active=True, expires_at__gt=timezone.now()
).count()
expired_actions = ModerationAction.objects.filter(
expires_at__lte=timezone.now()
).count()
stats_data = {
"total_actions": total_actions,
"active_actions": active_actions,
"expired_actions": expired_actions,
}
return Response(stats_data)

View File

@@ -1,288 +0,0 @@
"""
Rich Choice Objects for Parks Domain
This module defines all choice objects for the parks domain, replacing
the legacy tuple-based choices with rich choice objects.
"""
from apps.core.choices import RichChoice, ChoiceCategory
from apps.core.choices.registry import register_choices
# Park Status Choices
PARK_STATUSES = [
RichChoice(
value="OPERATING",
label="Operating",
description="Park is currently open and operating normally",
metadata={
'color': 'green',
'icon': 'check-circle',
'css_class': 'bg-green-100 text-green-800',
'sort_order': 1
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="CLOSED_TEMP",
label="Temporarily Closed",
description="Park is temporarily closed for maintenance, weather, or seasonal reasons",
metadata={
'color': 'yellow',
'icon': 'pause-circle',
'css_class': 'bg-yellow-100 text-yellow-800',
'sort_order': 2
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="CLOSED_PERM",
label="Permanently Closed",
description="Park has been permanently closed and will not reopen",
metadata={
'color': 'red',
'icon': 'x-circle',
'css_class': 'bg-red-100 text-red-800',
'sort_order': 3
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="UNDER_CONSTRUCTION",
label="Under Construction",
description="Park is currently being built or undergoing major renovation",
metadata={
'color': 'blue',
'icon': 'tool',
'css_class': 'bg-blue-100 text-blue-800',
'sort_order': 4
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="DEMOLISHED",
label="Demolished",
description="Park has been completely demolished and removed",
metadata={
'color': 'gray',
'icon': 'trash',
'css_class': 'bg-gray-100 text-gray-800',
'sort_order': 5
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="RELOCATED",
label="Relocated",
description="Park has been moved to a different location",
metadata={
'color': 'purple',
'icon': 'arrow-right',
'css_class': 'bg-purple-100 text-purple-800',
'sort_order': 6
},
category=ChoiceCategory.STATUS
),
]
# Park Type Choices
PARK_TYPES = [
RichChoice(
value="THEME_PARK",
label="Theme Park",
description="Large-scale amusement park with themed areas and attractions",
metadata={
'color': 'red',
'icon': 'castle',
'css_class': 'bg-red-100 text-red-800',
'sort_order': 1
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="AMUSEMENT_PARK",
label="Amusement Park",
description="Traditional amusement park with rides and games",
metadata={
'color': 'blue',
'icon': 'ferris-wheel',
'css_class': 'bg-blue-100 text-blue-800',
'sort_order': 2
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="WATER_PARK",
label="Water Park",
description="Park featuring water-based attractions and activities",
metadata={
'color': 'cyan',
'icon': 'water',
'css_class': 'bg-cyan-100 text-cyan-800',
'sort_order': 3
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="FAMILY_ENTERTAINMENT_CENTER",
label="Family Entertainment Center",
description="Indoor entertainment facility with games and family attractions",
metadata={
'color': 'green',
'icon': 'family',
'css_class': 'bg-green-100 text-green-800',
'sort_order': 4
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="CARNIVAL",
label="Carnival",
description="Traveling amusement show with rides, games, and entertainment",
metadata={
'color': 'yellow',
'icon': 'carnival',
'css_class': 'bg-yellow-100 text-yellow-800',
'sort_order': 5
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="FAIR",
label="Fair",
description="Temporary event featuring rides, games, and agricultural exhibits",
metadata={
'color': 'orange',
'icon': 'fair',
'css_class': 'bg-orange-100 text-orange-800',
'sort_order': 6
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="PIER",
label="Pier",
description="Seaside entertainment pier with rides and attractions",
metadata={
'color': 'teal',
'icon': 'pier',
'css_class': 'bg-teal-100 text-teal-800',
'sort_order': 7
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="BOARDWALK",
label="Boardwalk",
description="Waterfront entertainment area with rides and attractions",
metadata={
'color': 'indigo',
'icon': 'boardwalk',
'css_class': 'bg-indigo-100 text-indigo-800',
'sort_order': 8
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="SAFARI_PARK",
label="Safari Park",
description="Wildlife park with drive-through animal experiences",
metadata={
'color': 'emerald',
'icon': 'safari',
'css_class': 'bg-emerald-100 text-emerald-800',
'sort_order': 9
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="ZOO",
label="Zoo",
description="Zoological park with animal exhibits and educational programs",
metadata={
'color': 'lime',
'icon': 'zoo',
'css_class': 'bg-lime-100 text-lime-800',
'sort_order': 10
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="OTHER",
label="Other",
description="Park type that doesn't fit into standard categories",
metadata={
'color': 'gray',
'icon': 'other',
'css_class': 'bg-gray-100 text-gray-800',
'sort_order': 11
},
category=ChoiceCategory.CLASSIFICATION
),
]
# Company Role Choices for Parks Domain (OPERATOR and PROPERTY_OWNER only)
PARKS_COMPANY_ROLES = [
RichChoice(
value="OPERATOR",
label="Park Operator",
description="Company that operates and manages theme parks and amusement facilities",
metadata={
'color': 'blue',
'icon': 'building-office',
'css_class': 'bg-blue-100 text-blue-800',
'sort_order': 1,
'domain': 'parks',
'permissions': ['manage_parks', 'view_operations'],
'url_pattern': '/parks/operators/{slug}/'
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="PROPERTY_OWNER",
label="Property Owner",
description="Company that owns the land and property where parks are located",
metadata={
'color': 'green',
'icon': 'home',
'css_class': 'bg-green-100 text-green-800',
'sort_order': 2,
'domain': 'parks',
'permissions': ['manage_property', 'view_ownership'],
'url_pattern': '/parks/owners/{slug}/'
},
category=ChoiceCategory.CLASSIFICATION
),
]
def register_parks_choices():
"""Register all parks domain choices with the global registry"""
register_choices(
name="statuses",
choices=PARK_STATUSES,
domain="parks",
description="Park operational status options",
metadata={'domain': 'parks', 'type': 'status'}
)
register_choices(
name="types",
choices=PARK_TYPES,
domain="parks",
description="Park type and category classifications",
metadata={'domain': 'parks', 'type': 'park_type'}
)
register_choices(
name="company_roles",
choices=PARKS_COMPANY_ROLES,
domain="parks",
description="Company role classifications for parks domain (OPERATOR and PROPERTY_OWNER only)",
metadata={'domain': 'parks', 'type': 'company_role'}
)
# Auto-register choices when module is imported
register_parks_choices()

View File

@@ -1,198 +0,0 @@
"""
Django management command to run performance benchmarks.
"""
from django.core.management.base import BaseCommand
from django.utils import timezone
import json
import time
class Command(BaseCommand):
help = 'Run comprehensive performance benchmarks for park listing features'
def add_arguments(self, parser):
parser.add_argument(
'--save',
action='store_true',
help='Save detailed benchmark results to file',
)
parser.add_argument(
'--autocomplete-only',
action='store_true',
help='Run only autocomplete benchmarks',
)
parser.add_argument(
'--listing-only',
action='store_true',
help='Run only listing benchmarks',
)
parser.add_argument(
'--pagination-only',
action='store_true',
help='Run only pagination benchmarks',
)
parser.add_argument(
'--iterations',
type=int,
default=1,
help='Number of iterations to run (default: 1)',
)
def handle(self, *args, **options):
from apps.parks.services.performance_monitoring import BenchmarkSuite
self.stdout.write(
self.style.SUCCESS('Starting Park Listing Performance Benchmarks')
)
suite = BenchmarkSuite()
iterations = options['iterations']
all_results = []
for i in range(iterations):
if iterations > 1:
self.stdout.write(f'\nIteration {i + 1}/{iterations}')
start_time = time.perf_counter()
# Run specific benchmarks or full suite
if options['autocomplete_only']:
result = suite.run_autocomplete_benchmark()
elif options['listing_only']:
result = suite.run_listing_benchmark()
elif options['pagination_only']:
result = suite.run_pagination_benchmark()
else:
result = suite.run_full_benchmark_suite()
duration = time.perf_counter() - start_time
result['iteration'] = i + 1
result['benchmark_duration'] = duration
all_results.append(result)
# Display summary for this iteration
self._display_iteration_summary(result, duration)
# Display overall summary if multiple iterations
if iterations > 1:
self._display_overall_summary(all_results)
# Save results if requested
if options['save']:
self._save_results(all_results)
self.stdout.write(
self.style.SUCCESS('\\nBenchmark completed successfully!')
)
def _display_iteration_summary(self, result, duration):
"""Display summary for a single iteration."""
if 'overall_summary' in result:
summary = result['overall_summary']
self.stdout.write(f'\\nBenchmark Duration: {duration:.3f}s')
self.stdout.write(f'Total Operations: {summary["total_operations"]}')
self.stdout.write(f'Average Response Time: {summary["duration_stats"]["mean"]:.3f}s')
self.stdout.write(f'Average Query Count: {summary["query_stats"]["mean"]:.1f}')
self.stdout.write(f'Cache Hit Rate: {summary["cache_stats"]["hit_rate"]:.1f}%')
# Display slowest operations
if summary.get('slowest_operations'):
self.stdout.write('\\nSlowest Operations:')
for op in summary['slowest_operations'][:3]:
self.stdout.write(f' {op["operation"]}: {op["duration"]:.3f}s ({op["query_count"]} queries)')
# Display recommendations
if result.get('recommendations'):
self.stdout.write('\\nRecommendations:')
for rec in result['recommendations']:
self.stdout.write(f'{rec}')
# Display specific benchmark results
for benchmark_type in ['autocomplete', 'listing', 'pagination']:
if benchmark_type in result:
self._display_benchmark_results(benchmark_type, result[benchmark_type])
def _display_benchmark_results(self, benchmark_type, results):
"""Display results for a specific benchmark type."""
self.stdout.write(f'\\n{benchmark_type.title()} Benchmark Results:')
if benchmark_type == 'autocomplete':
for query_result in results.get('results', []):
self.stdout.write(
f' Query "{query_result["query"]}": {query_result["response_time"]:.3f}s '
f'({query_result["query_count"]} queries)'
)
elif benchmark_type == 'listing':
for scenario in results.get('results', []):
self.stdout.write(
f' {scenario["scenario"]}: {scenario["response_time"]:.3f}s '
f'({scenario["query_count"]} queries, {scenario["result_count"]} results)'
)
elif benchmark_type == 'pagination':
# Group by page size for cleaner display
by_page_size = {}
for result in results.get('results', []):
size = result['page_size']
if size not in by_page_size:
by_page_size[size] = []
by_page_size[size].append(result)
for page_size, page_results in by_page_size.items():
avg_time = sum(r['response_time'] for r in page_results) / len(page_results)
avg_queries = sum(r['query_count'] for r in page_results) / len(page_results)
self.stdout.write(
f' Page size {page_size}: avg {avg_time:.3f}s ({avg_queries:.1f} queries)'
)
def _display_overall_summary(self, all_results):
"""Display summary across all iterations."""
self.stdout.write('\\n' + '='*50)
self.stdout.write('OVERALL SUMMARY ACROSS ALL ITERATIONS')
self.stdout.write('='*50)
# Calculate averages across iterations
total_duration = sum(r['benchmark_duration'] for r in all_results)
# Extract performance metrics from iterations with overall_summary
overall_summaries = [r['overall_summary'] for r in all_results if 'overall_summary' in r]
if overall_summaries:
avg_response_time = sum(s['duration_stats']['mean'] for s in overall_summaries) / len(overall_summaries)
avg_query_count = sum(s['query_stats']['mean'] for s in overall_summaries) / len(overall_summaries)
avg_cache_hit_rate = sum(s['cache_stats']['hit_rate'] for s in overall_summaries) / len(overall_summaries)
self.stdout.write(f'Total Benchmark Time: {total_duration:.3f}s')
self.stdout.write(f'Average Response Time: {avg_response_time:.3f}s')
self.stdout.write(f'Average Query Count: {avg_query_count:.1f}')
self.stdout.write(f'Average Cache Hit Rate: {avg_cache_hit_rate:.1f}%')
def _save_results(self, results):
"""Save benchmark results to file."""
timestamp = timezone.now().strftime('%Y%m%d_%H%M%S')
filename = f'benchmark_results_{timestamp}.json'
try:
import os
# Ensure logs directory exists
logs_dir = 'logs'
os.makedirs(logs_dir, exist_ok=True)
filepath = os.path.join(logs_dir, filename)
with open(filepath, 'w') as f:
json.dump(results, f, indent=2, default=str)
self.stdout.write(
self.style.SUCCESS(f'Results saved to {filepath}')
)
except Exception as e:
self.stdout.write(
self.style.ERROR(f'Error saving results: {e}')
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,54 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-23 22:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('parks', '0001_initial'),
]
operations = [
# Performance indexes for frequently filtered fields
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS idx_parks_status_operator ON parks_park(status, operator_id);",
reverse_sql="DROP INDEX IF EXISTS idx_parks_status_operator;"
),
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS idx_parks_park_type_status ON parks_park(park_type, status);",
reverse_sql="DROP INDEX IF EXISTS idx_parks_park_type_status;"
),
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS idx_parks_opening_year_status ON parks_park(opening_year, status) WHERE opening_year IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS idx_parks_opening_year_status;"
),
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS idx_parks_ride_count_coaster_count ON parks_park(ride_count, coaster_count) WHERE ride_count IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS idx_parks_ride_count_coaster_count;"
),
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS idx_parks_average_rating_status ON parks_park(average_rating, status) WHERE average_rating IS NOT NULL;",
reverse_sql="DROP INDEX IF EXISTS idx_parks_average_rating_status;"
),
# Search optimization index
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS idx_parks_search_text_gin ON parks_park USING gin(search_text gin_trgm_ops);",
reverse_sql="DROP INDEX IF EXISTS idx_parks_search_text_gin;"
),
# Location-based indexes for ParkLocation
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS idx_parklocation_country_city ON parks_parklocation(country, city);",
reverse_sql="DROP INDEX IF EXISTS idx_parklocation_country_city;"
),
# Company name index for operator filtering
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS idx_company_name_roles ON parks_company USING gin(name gin_trgm_ops, roles);",
reverse_sql="DROP INDEX IF EXISTS idx_company_name_roles;"
),
# Timestamps for ordering and filtering
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS idx_parks_created_at_status ON parks_park(created_at, status);",
reverse_sql="DROP INDEX IF EXISTS idx_parks_created_at_status;"
),
]

File diff suppressed because one or more lines are too long

View File

@@ -1,428 +0,0 @@
"""
Smart Park Loader for Hybrid Filtering Strategy
This module provides intelligent data loading capabilities for the hybrid filtering approach,
optimizing database queries and implementing progressive loading strategies.
"""
from typing import Dict, Optional, Any
from django.db import models
from django.core.cache import cache
from django.conf import settings
from apps.parks.models import Park
class SmartParkLoader:
"""
Intelligent park data loader that optimizes queries based on filtering requirements.
Implements progressive loading and smart caching strategies.
"""
# Cache configuration
CACHE_TIMEOUT = getattr(settings, 'HYBRID_FILTER_CACHE_TIMEOUT', 300) # 5 minutes
CACHE_KEY_PREFIX = 'hybrid_parks'
# Progressive loading thresholds
INITIAL_LOAD_SIZE = 50
PROGRESSIVE_LOAD_SIZE = 25
MAX_CLIENT_SIDE_RECORDS = 200
def __init__(self):
self.base_queryset = self._get_optimized_queryset()
def _get_optimized_queryset(self) -> models.QuerySet:
"""Get optimized base queryset with all necessary prefetches."""
return Park.objects.select_related(
'operator',
'property_owner',
'banner_image',
'card_image',
).prefetch_related(
'location', # ParkLocation relationship
).filter(
# Only include operating and temporarily closed parks by default
status__in=['OPERATING', 'CLOSED_TEMP']
).order_by('name')
def get_initial_load(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Get initial park data load with smart filtering decisions.
Args:
filters: Optional filters to apply
Returns:
Dictionary containing parks data and metadata
"""
cache_key = self._generate_cache_key('initial', filters)
cached_result = cache.get(cache_key)
if cached_result:
return cached_result
# Apply filters if provided
queryset = self.base_queryset
if filters:
queryset = self._apply_filters(queryset, filters)
# Get total count for pagination decisions
total_count = queryset.count()
# Determine loading strategy
if total_count <= self.MAX_CLIENT_SIDE_RECORDS:
# Load all data for client-side filtering
parks = list(queryset.all())
strategy = 'client_side'
has_more = False
else:
# Load initial batch for server-side pagination
parks = list(queryset[:self.INITIAL_LOAD_SIZE])
strategy = 'server_side'
has_more = total_count > self.INITIAL_LOAD_SIZE
result = {
'parks': parks,
'total_count': total_count,
'strategy': strategy,
'has_more': has_more,
'next_offset': len(parks) if has_more else None,
'filter_metadata': self._get_filter_metadata(queryset),
}
# Cache the result
cache.set(cache_key, result, self.CACHE_TIMEOUT)
return result
def get_progressive_load(
self,
offset: int,
filters: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Get next batch of parks for progressive loading.
Args:
offset: Starting offset for the batch
filters: Optional filters to apply
Returns:
Dictionary containing parks data and metadata
"""
cache_key = self._generate_cache_key(f'progressive_{offset}', filters)
cached_result = cache.get(cache_key)
if cached_result:
return cached_result
# Apply filters if provided
queryset = self.base_queryset
if filters:
queryset = self._apply_filters(queryset, filters)
# Get the batch
end_offset = offset + self.PROGRESSIVE_LOAD_SIZE
parks = list(queryset[offset:end_offset])
# Check if there are more records
total_count = queryset.count()
has_more = end_offset < total_count
result = {
'parks': parks,
'total_count': total_count,
'has_more': has_more,
'next_offset': end_offset if has_more else None,
}
# Cache the result
cache.set(cache_key, result, self.CACHE_TIMEOUT)
return result
def get_filter_metadata(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Get metadata about available filter options.
Args:
filters: Current filters to scope the metadata
Returns:
Dictionary containing filter metadata
"""
cache_key = self._generate_cache_key('metadata', filters)
cached_result = cache.get(cache_key)
if cached_result:
return cached_result
# Apply filters if provided
queryset = self.base_queryset
if filters:
queryset = self._apply_filters(queryset, filters)
result = self._get_filter_metadata(queryset)
# Cache the result
cache.set(cache_key, result, self.CACHE_TIMEOUT)
return result
def _apply_filters(self, queryset: models.QuerySet, filters: Dict[str, Any]) -> models.QuerySet:
"""Apply filters to the queryset."""
# Status filter
if 'status' in filters and filters['status']:
if isinstance(filters['status'], list):
queryset = queryset.filter(status__in=filters['status'])
else:
queryset = queryset.filter(status=filters['status'])
# Park type filter
if 'park_type' in filters and filters['park_type']:
if isinstance(filters['park_type'], list):
queryset = queryset.filter(park_type__in=filters['park_type'])
else:
queryset = queryset.filter(park_type=filters['park_type'])
# Country filter
if 'country' in filters and filters['country']:
queryset = queryset.filter(location__country__in=filters['country'])
# State filter
if 'state' in filters and filters['state']:
queryset = queryset.filter(location__state__in=filters['state'])
# Opening year range
if 'opening_year_min' in filters and filters['opening_year_min']:
queryset = queryset.filter(opening_year__gte=filters['opening_year_min'])
if 'opening_year_max' in filters and filters['opening_year_max']:
queryset = queryset.filter(opening_year__lte=filters['opening_year_max'])
# Size range
if 'size_min' in filters and filters['size_min']:
queryset = queryset.filter(size_acres__gte=filters['size_min'])
if 'size_max' in filters and filters['size_max']:
queryset = queryset.filter(size_acres__lte=filters['size_max'])
# Rating range
if 'rating_min' in filters and filters['rating_min']:
queryset = queryset.filter(average_rating__gte=filters['rating_min'])
if 'rating_max' in filters and filters['rating_max']:
queryset = queryset.filter(average_rating__lte=filters['rating_max'])
# Ride count range
if 'ride_count_min' in filters and filters['ride_count_min']:
queryset = queryset.filter(ride_count__gte=filters['ride_count_min'])
if 'ride_count_max' in filters and filters['ride_count_max']:
queryset = queryset.filter(ride_count__lte=filters['ride_count_max'])
# Coaster count range
if 'coaster_count_min' in filters and filters['coaster_count_min']:
queryset = queryset.filter(coaster_count__gte=filters['coaster_count_min'])
if 'coaster_count_max' in filters and filters['coaster_count_max']:
queryset = queryset.filter(coaster_count__lte=filters['coaster_count_max'])
# Operator filter
if 'operator' in filters and filters['operator']:
if isinstance(filters['operator'], list):
queryset = queryset.filter(operator__slug__in=filters['operator'])
else:
queryset = queryset.filter(operator__slug=filters['operator'])
# Search query
if 'search' in filters and filters['search']:
search_term = filters['search'].lower()
queryset = queryset.filter(search_text__icontains=search_term)
return queryset
def _get_filter_metadata(self, queryset: models.QuerySet) -> Dict[str, Any]:
"""Generate filter metadata from the current queryset."""
# Get distinct values for categorical filters with counts
countries_data = list(
queryset.values('location__country')
.exclude(location__country__isnull=True)
.annotate(count=models.Count('id'))
.order_by('location__country')
)
states_data = list(
queryset.values('location__state')
.exclude(location__state__isnull=True)
.annotate(count=models.Count('id'))
.order_by('location__state')
)
park_types_data = list(
queryset.values('park_type')
.exclude(park_type__isnull=True)
.annotate(count=models.Count('id'))
.order_by('park_type')
)
statuses_data = list(
queryset.values('status')
.annotate(count=models.Count('id'))
.order_by('status')
)
operators_data = list(
queryset.select_related('operator')
.values('operator__id', 'operator__name', 'operator__slug')
.exclude(operator__isnull=True)
.annotate(count=models.Count('id'))
.order_by('operator__name')
)
# Convert to frontend-expected format with value/label/count
countries = [
{
'value': item['location__country'],
'label': item['location__country'],
'count': item['count']
}
for item in countries_data
]
states = [
{
'value': item['location__state'],
'label': item['location__state'],
'count': item['count']
}
for item in states_data
]
park_types = [
{
'value': item['park_type'],
'label': item['park_type'],
'count': item['count']
}
for item in park_types_data
]
statuses = [
{
'value': item['status'],
'label': self._get_status_label(item['status']),
'count': item['count']
}
for item in statuses_data
]
operators = [
{
'value': item['operator__slug'],
'label': item['operator__name'],
'count': item['count']
}
for item in operators_data
]
# Get ranges for numerical filters
aggregates = queryset.aggregate(
opening_year_min=models.Min('opening_year'),
opening_year_max=models.Max('opening_year'),
size_min=models.Min('size_acres'),
size_max=models.Max('size_acres'),
rating_min=models.Min('average_rating'),
rating_max=models.Max('average_rating'),
ride_count_min=models.Min('ride_count'),
ride_count_max=models.Max('ride_count'),
coaster_count_min=models.Min('coaster_count'),
coaster_count_max=models.Max('coaster_count'),
)
return {
'categorical': {
'countries': countries,
'states': states,
'park_types': park_types,
'statuses': statuses,
'operators': operators,
},
'ranges': {
'opening_year': {
'min': aggregates['opening_year_min'],
'max': aggregates['opening_year_max'],
'step': 1,
'unit': 'year'
},
'size_acres': {
'min': float(aggregates['size_min']) if aggregates['size_min'] else None,
'max': float(aggregates['size_max']) if aggregates['size_max'] else None,
'step': 1.0,
'unit': 'acres'
},
'average_rating': {
'min': float(aggregates['rating_min']) if aggregates['rating_min'] else None,
'max': float(aggregates['rating_max']) if aggregates['rating_max'] else None,
'step': 0.1,
'unit': 'stars'
},
'ride_count': {
'min': aggregates['ride_count_min'],
'max': aggregates['ride_count_max'],
'step': 1,
'unit': 'rides'
},
'coaster_count': {
'min': aggregates['coaster_count_min'],
'max': aggregates['coaster_count_max'],
'step': 1,
'unit': 'coasters'
},
},
'total_count': queryset.count(),
}
def _get_status_label(self, status: str) -> str:
"""Convert status code to human-readable label."""
status_labels = {
'OPERATING': 'Operating',
'CLOSED_TEMP': 'Temporarily Closed',
'CLOSED_PERM': 'Permanently Closed',
'UNDER_CONSTRUCTION': 'Under Construction',
}
if status in status_labels:
return status_labels[status]
else:
raise ValueError(f"Unknown park status: {status}")
def _generate_cache_key(self, operation: str, filters: Optional[Dict[str, Any]] = None) -> str:
"""Generate cache key for the given operation and filters."""
key_parts = [self.CACHE_KEY_PREFIX, operation]
if filters:
# Create a consistent string representation of filters
filter_str = '_'.join(f"{k}:{v}" for k, v in sorted(filters.items()) if v)
key_parts.append(filter_str)
return '_'.join(key_parts)
def invalidate_cache(self, filters: Optional[Dict[str, Any]] = None) -> None:
"""Invalidate cached data for the given filters."""
# This is a simplified implementation
# In production, you might want to use cache versioning or tags
cache_keys = [
self._generate_cache_key('initial', filters),
self._generate_cache_key('metadata', filters),
]
# Also invalidate progressive load caches
for offset in range(0, 1000, self.PROGRESSIVE_LOAD_SIZE):
cache_keys.append(self._generate_cache_key(f'progressive_{offset}', filters))
cache.delete_many(cache_keys)
# Singleton instance
smart_park_loader = SmartParkLoader()

View File

@@ -1,311 +0,0 @@
"""
Optimized pagination service for large datasets with efficient counting.
"""
from typing import Dict, Any, Optional, Tuple
from django.core.paginator import Paginator, Page
from django.core.cache import cache
from django.db.models import QuerySet, Count
from django.conf import settings
import hashlib
import time
import logging
logger = logging.getLogger("pagination_service")
class OptimizedPaginator(Paginator):
"""
Custom paginator that optimizes COUNT queries and provides caching.
"""
def __init__(self, object_list, per_page, cache_timeout=300, **kwargs):
super().__init__(object_list, per_page, **kwargs)
self.cache_timeout = cache_timeout
self._cached_count = None
self._count_cache_key = None
def _get_count_cache_key(self) -> str:
"""Generate cache key for count based on queryset SQL."""
if self._count_cache_key:
return self._count_cache_key
# Create cache key from queryset SQL
if hasattr(self.object_list, 'query'):
sql_hash = hashlib.md5(
str(self.object_list.query).encode('utf-8')
).hexdigest()[:16]
self._count_cache_key = f"paginator_count:{sql_hash}"
else:
# Fallback for non-queryset object lists
self._count_cache_key = f"paginator_count:list:{len(self.object_list)}"
return self._count_cache_key
@property
def count(self):
"""
Optimized count with caching for expensive querysets.
"""
if self._cached_count is not None:
return self._cached_count
cache_key = self._get_count_cache_key()
cached_count = cache.get(cache_key)
if cached_count is not None:
logger.debug(f"Cache hit for pagination count: {cache_key}")
self._cached_count = cached_count
return cached_count
# Perform optimized count
start_time = time.time()
if hasattr(self.object_list, 'count'):
# For QuerySets, try to optimize the count query
count = self._get_optimized_count()
else:
count = len(self.object_list)
execution_time = time.time() - start_time
# Cache the result
cache.set(cache_key, count, self.cache_timeout)
self._cached_count = count
if execution_time > 0.5: # Log slow count queries
logger.warning(
f"Slow pagination count query: {execution_time:.3f}s for {count} items",
extra={'cache_key': cache_key, 'execution_time': execution_time}
)
return count
def _get_optimized_count(self) -> int:
"""
Get optimized count for complex querysets.
"""
queryset = self.object_list
# For complex queries with joins, use approximate counting for very large datasets
if self._is_complex_query(queryset):
# Try to get count from a simpler subquery
try:
# Use subquery approach for complex queries
subquery = queryset.values('pk')
return subquery.count()
except Exception as e:
logger.warning(f"Optimized count failed, falling back to standard count: {e}")
return queryset.count()
else:
return queryset.count()
def _is_complex_query(self, queryset) -> bool:
"""
Determine if a queryset is complex and might benefit from optimization.
"""
if not hasattr(queryset, 'query'):
return False
sql = str(queryset.query).upper()
# Consider complex if it has multiple joins or subqueries
complexity_indicators = [
'JOIN' in sql and sql.count('JOIN') > 2,
'DISTINCT' in sql,
'GROUP BY' in sql,
'HAVING' in sql,
]
return any(complexity_indicators)
class CursorPaginator:
"""
Cursor-based pagination for very large datasets.
More efficient than offset-based pagination for large page numbers.
"""
def __init__(self, queryset: QuerySet, ordering_field: str = 'id', per_page: int = 20):
self.queryset = queryset
self.ordering_field = ordering_field
self.per_page = per_page
self.reverse = ordering_field.startswith('-')
self.field_name = ordering_field.lstrip('-')
def get_page(self, cursor: Optional[str] = None) -> Dict[str, Any]:
"""
Get a page of results using cursor-based pagination.
Args:
cursor: Base64 encoded cursor value from previous page
Returns:
Dictionary with page data and navigation cursors
"""
queryset = self.queryset.order_by(self.ordering_field)
if cursor:
# Decode cursor and filter from that point
try:
cursor_value = self._decode_cursor(cursor)
if self.reverse:
queryset = queryset.filter(**{f"{self.field_name}__lt": cursor_value})
else:
queryset = queryset.filter(**{f"{self.field_name}__gt": cursor_value})
except (ValueError, TypeError):
# Invalid cursor, start from beginning
pass
# Get one extra item to check if there's a next page
items = list(queryset[:self.per_page + 1])
has_next = len(items) > self.per_page
if has_next:
items = items[:-1] # Remove the extra item
# Generate cursors for navigation
next_cursor = None
previous_cursor = None
if items and has_next:
last_item = items[-1]
next_cursor = self._encode_cursor(getattr(last_item, self.field_name))
if items and cursor:
first_item = items[0]
previous_cursor = self._encode_cursor(getattr(first_item, self.field_name))
return {
'items': items,
'has_next': has_next,
'has_previous': cursor is not None,
'next_cursor': next_cursor,
'previous_cursor': previous_cursor,
'count': len(items)
}
def _encode_cursor(self, value) -> str:
"""Encode cursor value to base64 string."""
import base64
return base64.b64encode(str(value).encode()).decode()
def _decode_cursor(self, cursor: str):
"""Decode cursor from base64 string."""
import base64
decoded = base64.b64decode(cursor.encode()).decode()
# Try to convert to appropriate type based on field
field = self.queryset.model._meta.get_field(self.field_name)
if hasattr(field, 'to_python'):
return field.to_python(decoded)
return decoded
class PaginationCache:
"""
Advanced caching for pagination metadata and results.
"""
CACHE_PREFIX = "pagination"
DEFAULT_TIMEOUT = 300 # 5 minutes
@classmethod
def get_page_cache_key(cls, queryset_hash: str, page_num: int) -> str:
"""Generate cache key for a specific page."""
return f"{cls.CACHE_PREFIX}:page:{queryset_hash}:{page_num}"
@classmethod
def get_metadata_cache_key(cls, queryset_hash: str) -> str:
"""Generate cache key for pagination metadata."""
return f"{cls.CACHE_PREFIX}:meta:{queryset_hash}"
@classmethod
def cache_page_results(
cls,
queryset_hash: str,
page_num: int,
page_data: Dict[str, Any],
timeout: int = DEFAULT_TIMEOUT
):
"""Cache page results."""
cache_key = cls.get_page_cache_key(queryset_hash, page_num)
cache.set(cache_key, page_data, timeout)
@classmethod
def get_cached_page(cls, queryset_hash: str, page_num: int) -> Optional[Dict[str, Any]]:
"""Get cached page results."""
cache_key = cls.get_page_cache_key(queryset_hash, page_num)
return cache.get(cache_key)
@classmethod
def cache_metadata(
cls,
queryset_hash: str,
metadata: Dict[str, Any],
timeout: int = DEFAULT_TIMEOUT
):
"""Cache pagination metadata."""
cache_key = cls.get_metadata_cache_key(queryset_hash)
cache.set(cache_key, metadata, timeout)
@classmethod
def get_cached_metadata(cls, queryset_hash: str) -> Optional[Dict[str, Any]]:
"""Get cached pagination metadata."""
cache_key = cls.get_metadata_cache_key(queryset_hash)
return cache.get(cache_key)
@classmethod
def invalidate_cache(cls, queryset_hash: str):
"""Invalidate all cache entries for a queryset."""
# This would require a cache backend that supports pattern deletion
# For now, we'll rely on TTL expiration
pass
def get_optimized_page(
queryset: QuerySet,
page_number: int,
per_page: int = 20,
use_cursor: bool = False,
cursor: Optional[str] = None,
cache_timeout: int = 300
) -> Tuple[Page, Dict[str, Any]]:
"""
Get an optimized page with caching and performance monitoring.
Args:
queryset: The queryset to paginate
page_number: Page number to retrieve
per_page: Items per page
use_cursor: Whether to use cursor-based pagination
cursor: Cursor for cursor-based pagination
cache_timeout: Cache timeout in seconds
Returns:
Tuple of (Page object, metadata dict)
"""
if use_cursor:
paginator = CursorPaginator(queryset, per_page=per_page)
page_data = paginator.get_page(cursor)
return page_data, {
'pagination_type': 'cursor',
'has_next': page_data['has_next'],
'has_previous': page_data['has_previous'],
'next_cursor': page_data['next_cursor'],
'previous_cursor': page_data['previous_cursor']
}
else:
paginator = OptimizedPaginator(queryset, per_page, cache_timeout=cache_timeout)
page = paginator.get_page(page_number)
return page, {
'pagination_type': 'offset',
'total_pages': paginator.num_pages,
'total_count': paginator.count,
'has_next': page.has_next(),
'has_previous': page.has_previous(),
'current_page': page.number
}

View File

@@ -1,402 +0,0 @@
"""
Performance monitoring and benchmarking tools for park listing optimizations.
"""
import time
import logging
import statistics
from typing import Dict, List, Any, Optional, Callable
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from django.db import connection
from django.core.cache import cache
from django.conf import settings
from django.test import RequestFactory
import json
logger = logging.getLogger("performance_monitoring")
@dataclass
class PerformanceMetric:
"""Data class for storing performance metrics."""
operation: str
duration: float
query_count: int
cache_hits: int = 0
cache_misses: int = 0
memory_usage: Optional[float] = None
timestamp: datetime = field(default_factory=datetime.now)
metadata: Dict[str, Any] = field(default_factory=dict)
class PerformanceMonitor:
"""
Comprehensive performance monitoring for park listing operations.
"""
def __init__(self):
self.metrics: List[PerformanceMetric] = []
self.cache_stats = {'hits': 0, 'misses': 0}
@contextmanager
def measure_operation(self, operation_name: str, **metadata):
"""Context manager to measure operation performance."""
initial_queries = len(connection.queries) if hasattr(connection, 'queries') else 0
initial_cache_hits = self.cache_stats['hits']
initial_cache_misses = self.cache_stats['misses']
start_time = time.perf_counter()
start_memory = self._get_memory_usage()
try:
yield
finally:
end_time = time.perf_counter()
end_memory = self._get_memory_usage()
duration = end_time - start_time
query_count = (len(connection.queries) - initial_queries) if hasattr(connection, 'queries') else 0
cache_hits = self.cache_stats['hits'] - initial_cache_hits
cache_misses = self.cache_stats['misses'] - initial_cache_misses
memory_delta = end_memory - start_memory if start_memory and end_memory else None
metric = PerformanceMetric(
operation=operation_name,
duration=duration,
query_count=query_count,
cache_hits=cache_hits,
cache_misses=cache_misses,
memory_usage=memory_delta,
metadata=metadata
)
self.metrics.append(metric)
self._log_metric(metric)
def _get_memory_usage(self) -> Optional[float]:
"""Get current memory usage in MB."""
try:
import psutil
process = psutil.Process()
return process.memory_info().rss / 1024 / 1024 # Convert to MB
except ImportError:
return None
def _log_metric(self, metric: PerformanceMetric):
"""Log performance metric with appropriate level."""
message = (
f"{metric.operation}: {metric.duration:.3f}s, "
f"{metric.query_count} queries, "
f"{metric.cache_hits} cache hits"
)
if metric.memory_usage:
message += f", {metric.memory_usage:.2f}MB memory delta"
# Log as warning if performance is concerning
if metric.duration > 1.0 or metric.query_count > 10:
logger.warning(f"Performance concern: {message}")
else:
logger.info(f"Performance metric: {message}")
def get_performance_summary(self) -> Dict[str, Any]:
"""Get summary of all performance metrics."""
if not self.metrics:
return {'message': 'No metrics collected'}
durations = [m.duration for m in self.metrics]
query_counts = [m.query_count for m in self.metrics]
return {
'total_operations': len(self.metrics),
'duration_stats': {
'mean': statistics.mean(durations),
'median': statistics.median(durations),
'min': min(durations),
'max': max(durations),
'total': sum(durations)
},
'query_stats': {
'mean': statistics.mean(query_counts),
'median': statistics.median(query_counts),
'min': min(query_counts),
'max': max(query_counts),
'total': sum(query_counts)
},
'cache_stats': {
'total_hits': sum(m.cache_hits for m in self.metrics),
'total_misses': sum(m.cache_misses for m in self.metrics),
'hit_rate': self._calculate_cache_hit_rate()
},
'slowest_operations': self._get_slowest_operations(5),
'most_query_intensive': self._get_most_query_intensive(5)
}
def _calculate_cache_hit_rate(self) -> float:
"""Calculate overall cache hit rate."""
total_hits = sum(m.cache_hits for m in self.metrics)
total_requests = total_hits + sum(m.cache_misses for m in self.metrics)
return (total_hits / total_requests * 100) if total_requests > 0 else 0.0
def _get_slowest_operations(self, count: int) -> List[Dict[str, Any]]:
"""Get the slowest operations."""
sorted_metrics = sorted(self.metrics, key=lambda m: m.duration, reverse=True)
return [
{
'operation': m.operation,
'duration': m.duration,
'query_count': m.query_count,
'timestamp': m.timestamp.isoformat()
}
for m in sorted_metrics[:count]
]
def _get_most_query_intensive(self, count: int) -> List[Dict[str, Any]]:
"""Get operations with the most database queries."""
sorted_metrics = sorted(self.metrics, key=lambda m: m.query_count, reverse=True)
return [
{
'operation': m.operation,
'query_count': m.query_count,
'duration': m.duration,
'timestamp': m.timestamp.isoformat()
}
for m in sorted_metrics[:count]
]
class BenchmarkSuite:
"""
Comprehensive benchmarking suite for park listing performance.
"""
def __init__(self):
self.monitor = PerformanceMonitor()
self.factory = RequestFactory()
def run_autocomplete_benchmark(self, queries: List[str] = None) -> Dict[str, Any]:
"""Benchmark autocomplete performance with various queries."""
if not queries:
queries = [
'Di', # Short query
'Disney', # Common brand
'Universal', # Another common brand
'Cedar Point', # Specific park
'California', # Location
'Roller', # Generic term
'Xyz123' # Non-existent query
]
results = []
for query in queries:
with self.monitor.measure_operation(f"autocomplete_{query}", query=query):
# Simulate autocomplete request
from apps.parks.views_autocomplete import ParkAutocompleteView
request = self.factory.get(f'/api/parks/autocomplete/?q={query}')
view = ParkAutocompleteView()
response = view.get(request)
results.append({
'query': query,
'status_code': response.status_code,
'response_time': self.monitor.metrics[-1].duration,
'query_count': self.monitor.metrics[-1].query_count
})
return {
'benchmark_type': 'autocomplete',
'queries_tested': len(queries),
'results': results,
'summary': self.monitor.get_performance_summary()
}
def run_listing_benchmark(self, scenarios: List[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Benchmark park listing performance with various filter scenarios."""
if not scenarios:
scenarios = [
{'name': 'no_filters', 'params': {}},
{'name': 'status_filter', 'params': {'status': 'OPERATING'}},
{'name': 'operator_filter', 'params': {'operator': 'Disney'}},
{'name': 'location_filter', 'params': {'country': 'United States'}},
{'name': 'complex_filter', 'params': {
'status': 'OPERATING',
'has_coasters': 'true',
'min_rating': '4.0'
}},
{'name': 'search_query', 'params': {'search': 'Magic Kingdom'}},
{'name': 'pagination_last_page', 'params': {'page': '10'}}
]
results = []
for scenario in scenarios:
with self.monitor.measure_operation(f"listing_{scenario['name']}", **scenario['params']):
# Simulate listing request
from apps.parks.views import ParkListView
query_string = '&'.join([f"{k}={v}" for k, v in scenario['params'].items()])
request = self.factory.get(f'/parks/?{query_string}')
view = ParkListView()
view.setup(request)
# Simulate getting the queryset and context
queryset = view.get_queryset()
context = view.get_context_data()
results.append({
'scenario': scenario['name'],
'params': scenario['params'],
'result_count': queryset.count() if hasattr(queryset, 'count') else len(queryset),
'response_time': self.monitor.metrics[-1].duration,
'query_count': self.monitor.metrics[-1].query_count
})
return {
'benchmark_type': 'listing',
'scenarios_tested': len(scenarios),
'results': results,
'summary': self.monitor.get_performance_summary()
}
def run_pagination_benchmark(self, page_sizes: List[int] = None, page_numbers: List[int] = None) -> Dict[str, Any]:
"""Benchmark pagination performance with different page sizes and numbers."""
if not page_sizes:
page_sizes = [10, 20, 50, 100]
if not page_numbers:
page_numbers = [1, 5, 10, 50]
results = []
for page_size in page_sizes:
for page_number in page_numbers:
scenario_name = f"page_{page_number}_size_{page_size}"
with self.monitor.measure_operation(scenario_name, page_size=page_size, page_number=page_number):
from apps.parks.services.pagination_service import get_optimized_page
from apps.parks.querysets import get_base_park_queryset
queryset = get_base_park_queryset()
page, metadata = get_optimized_page(queryset, page_number, page_size)
results.append({
'page_size': page_size,
'page_number': page_number,
'total_count': metadata.get('total_count', 0),
'response_time': self.monitor.metrics[-1].duration,
'query_count': self.monitor.metrics[-1].query_count
})
return {
'benchmark_type': 'pagination',
'configurations_tested': len(results),
'results': results,
'summary': self.monitor.get_performance_summary()
}
def run_full_benchmark_suite(self) -> Dict[str, Any]:
"""Run the complete benchmark suite."""
logger.info("Starting comprehensive benchmark suite")
suite_start = time.perf_counter()
# Run all benchmarks
autocomplete_results = self.run_autocomplete_benchmark()
listing_results = self.run_listing_benchmark()
pagination_results = self.run_pagination_benchmark()
suite_duration = time.perf_counter() - suite_start
# Generate comprehensive report
report = {
'benchmark_suite': 'Park Listing Performance',
'timestamp': datetime.now().isoformat(),
'total_duration': suite_duration,
'autocomplete': autocomplete_results,
'listing': listing_results,
'pagination': pagination_results,
'overall_summary': self.monitor.get_performance_summary(),
'recommendations': self._generate_recommendations()
}
# Save report
self._save_benchmark_report(report)
logger.info(f"Benchmark suite completed in {suite_duration:.3f}s")
return report
def _generate_recommendations(self) -> List[str]:
"""Generate performance recommendations based on benchmark results."""
recommendations = []
summary = self.monitor.get_performance_summary()
# Check average response times
if summary['duration_stats']['mean'] > 0.5:
recommendations.append("Average response time is high (>500ms). Consider implementing additional caching.")
# Check query counts
if summary['query_stats']['mean'] > 5:
recommendations.append("High average query count. Review and optimize database queries.")
# Check cache hit rate
if summary['cache_stats']['hit_rate'] < 80:
recommendations.append("Cache hit rate is low (<80%). Increase cache timeouts or improve cache key strategy.")
# Check for slow operations
slowest = summary.get('slowest_operations', [])
if slowest and slowest[0]['duration'] > 2.0:
recommendations.append(f"Slowest operation ({slowest[0]['operation']}) is very slow (>{slowest[0]['duration']:.2f}s).")
if not recommendations:
recommendations.append("Performance appears to be within acceptable ranges.")
return recommendations
def _save_benchmark_report(self, report: Dict[str, Any]):
"""Save benchmark report to file and cache."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"benchmark_report_{timestamp}.json"
try:
# Save to logs directory
import os
logs_dir = "logs"
os.makedirs(logs_dir, exist_ok=True)
filepath = os.path.join(logs_dir, filename)
with open(filepath, 'w') as f:
json.dump(report, f, indent=2, default=str)
logger.info(f"Benchmark report saved to {filepath}")
# Also cache the report
cache.set(f"benchmark_report_latest", report, 3600) # 1 hour
except Exception as e:
logger.error(f"Error saving benchmark report: {e}")
# Global performance monitor instance
performance_monitor = PerformanceMonitor()
def benchmark_operation(operation_name: str):
"""Decorator to benchmark a function."""
def decorator(func: Callable):
def wrapper(*args, **kwargs):
with performance_monitor.measure_operation(operation_name):
return func(*args, **kwargs)
return wrapper
return decorator
# Convenience function to run benchmarks
def run_performance_benchmark():
"""Run the complete performance benchmark suite."""
suite = BenchmarkSuite()
return suite.run_full_benchmark_suite()

View File

@@ -1,363 +0,0 @@
/* Performance-optimized CSS for park listing page */
/* Critical CSS that should be inlined */
.park-listing {
/* Use GPU acceleration for smooth animations */
transform: translateZ(0);
backface-visibility: hidden;
}
/* Lazy loading image styles */
img[data-src] {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
transition: opacity 0.3s ease;
}
img.loading {
opacity: 0.7;
filter: blur(2px);
}
img.loaded {
opacity: 1;
filter: none;
animation: none;
}
img.error {
background: #f5f5f5;
opacity: 0.5;
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* Optimized grid layout using CSS Grid */
.park-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
/* Use containment for better performance */
contain: layout style;
}
.park-card {
/* Optimize for animations */
will-change: transform, box-shadow;
transition: transform 0.2s ease, box-shadow 0.2s ease;
/* Enable GPU acceleration */
transform: translateZ(0);
/* Optimize paint */
contain: layout style paint;
}
.park-card:hover {
transform: translateY(-4px) translateZ(0);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
/* Efficient loading states */
.loading {
position: relative;
overflow: hidden;
}
.loading::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.4),
transparent
);
animation: loading-sweep 1.5s infinite;
pointer-events: none;
}
@keyframes loading-sweep {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
/* Optimized autocomplete dropdown */
.autocomplete-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 4px 4px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
z-index: 1000;
max-height: 300px;
overflow-y: auto;
/* Hide by default */
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all 0.2s ease;
/* Optimize scrolling */
-webkit-overflow-scrolling: touch;
contain: layout style;
}
.autocomplete-suggestions.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.suggestion-item {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.15s ease;
}
.suggestion-item:hover,
.suggestion-item.active {
background-color: #f8f9fa;
}
.suggestion-icon {
margin-right: 0.5rem;
font-size: 0.875rem;
}
.suggestion-name {
font-weight: 500;
flex-grow: 1;
}
.suggestion-details {
font-size: 0.875rem;
color: #666;
}
/* Optimized filter panel */
.filter-panel {
/* Use flexbox for efficient layout */
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
/* Optimize for frequent updates */
contain: layout style;
}
.filter-group {
display: flex;
flex-direction: column;
min-width: 150px;
}
.filter-input {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
transition: border-color 0.15s ease;
}
.filter-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
/* Performance-optimized pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
margin: 2rem 0;
/* Optimize for position changes */
contain: layout;
}
.pagination-btn {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
background: white;
color: #333;
text-decoration: none;
border-radius: 4px;
transition: all 0.15s ease;
/* Optimize for hover effects */
will-change: background-color, border-color;
}
.pagination-btn:hover:not(.disabled) {
background: #f8f9fa;
border-color: #bbb;
}
.pagination-btn.active {
background: #007bff;
color: white;
border-color: #007bff;
}
.pagination-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive optimizations */
@media (max-width: 768px) {
.park-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.filter-panel {
flex-direction: column;
}
.suggestion-item {
padding: 1rem;
}
}
/* High DPI optimizations */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
.park-card img {
/* Use higher quality images on retina displays */
image-rendering: -webkit-optimize-contrast;
}
}
/* Reduce motion for accessibility */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Performance debugging styles (only in development) */
.debug-metrics {
position: fixed;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-family: monospace;
z-index: 9999;
display: none;
}
body.debug .debug-metrics {
display: block;
}
.debug-metrics span {
display: block;
margin-bottom: 0.25rem;
}
/* Print optimizations */
@media print {
.autocomplete-suggestions,
.filter-panel,
.pagination,
.debug-metrics {
display: none;
}
.park-grid {
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.park-card {
break-inside: avoid;
page-break-inside: avoid;
}
}
/* Container queries for better responsive design */
@container (max-width: 400px) {
.park-card {
padding: 1rem;
}
.park-card img {
height: 150px;
}
}
/* Focus management for better accessibility */
.skip-link {
position: absolute;
top: -40px;
left: 6px;
background: #000;
color: white;
padding: 8px;
text-decoration: none;
border-radius: 4px;
z-index: 10000;
}
.skip-link:focus {
top: 6px;
}
/* Efficient animations using transform and opacity only */
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Optimize for critical rendering path */
.above-fold {
/* Ensure critical content renders first */
contain: layout style paint;
}
.below-fold {
/* Defer non-critical content */
content-visibility: auto;
contain-intrinsic-size: 500px;
}

View File

@@ -1,518 +0,0 @@
/**
* Performance-optimized JavaScript for park listing page
* Implements lazy loading, debouncing, and efficient DOM manipulation
*/
class ParkListingPerformance {
constructor() {
this.searchTimeout = null;
this.lastScrollPosition = 0;
this.observerOptions = {
root: null,
rootMargin: '50px',
threshold: 0.1
};
this.init();
}
init() {
this.setupLazyLoading();
this.setupDebouncedSearch();
this.setupOptimizedFiltering();
this.setupProgressiveImageLoading();
this.setupPerformanceMonitoring();
}
/**
* Setup lazy loading for park images using Intersection Observer
*/
setupLazyLoading() {
if ('IntersectionObserver' in window) {
this.imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadImage(entry.target);
this.imageObserver.unobserve(entry.target);
}
});
}, this.observerOptions);
// Observe all lazy images
document.querySelectorAll('img[data-src]').forEach(img => {
this.imageObserver.observe(img);
});
} else {
// Fallback for browsers without Intersection Observer
this.loadAllImages();
}
}
/**
* Load individual image with error handling and placeholder
*/
loadImage(img) {
const src = img.dataset.src;
const placeholder = img.dataset.placeholder;
// Start with low quality placeholder
if (placeholder && !img.src) {
img.src = placeholder;
img.classList.add('loading');
}
// Load high quality image
const highQualityImg = new Image();
highQualityImg.onload = () => {
img.src = highQualityImg.src;
img.classList.remove('loading');
img.classList.add('loaded');
};
highQualityImg.onerror = () => {
img.src = '/static/images/placeholders/park-placeholder.jpg';
img.classList.add('error');
};
highQualityImg.src = src;
}
/**
* Load all images (fallback for older browsers)
*/
loadAllImages() {
document.querySelectorAll('img[data-src]').forEach(img => {
this.loadImage(img);
});
}
/**
* Setup debounced search to reduce API calls
*/
setupDebouncedSearch() {
const searchInput = document.querySelector('[data-autocomplete]');
if (!searchInput) return;
searchInput.addEventListener('input', (e) => {
clearTimeout(this.searchTimeout);
const query = e.target.value.trim();
if (query.length < 2) {
this.hideSuggestions();
return;
}
// Debounce search requests
this.searchTimeout = setTimeout(() => {
this.performSearch(query);
}, 300);
});
// Handle keyboard navigation
searchInput.addEventListener('keydown', (e) => {
this.handleSearchKeyboard(e);
});
}
/**
* Perform optimized search with caching
*/
async performSearch(query) {
const cacheKey = `search_${query.toLowerCase()}`;
// Check session storage for cached results
const cached = sessionStorage.getItem(cacheKey);
if (cached) {
const results = JSON.parse(cached);
this.displaySuggestions(results);
return;
}
try {
const response = await fetch(`/api/parks/autocomplete/?q=${encodeURIComponent(query)}`, {
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
if (response.ok) {
const data = await response.json();
// Cache results for session
sessionStorage.setItem(cacheKey, JSON.stringify(data));
this.displaySuggestions(data);
}
} catch (error) {
console.error('Search error:', error);
this.hideSuggestions();
}
}
/**
* Display search suggestions with efficient DOM manipulation
*/
displaySuggestions(data) {
const container = document.querySelector('[data-suggestions]');
if (!container) return;
// Use document fragment for efficient DOM updates
const fragment = document.createDocumentFragment();
if (data.suggestions && data.suggestions.length > 0) {
data.suggestions.forEach(suggestion => {
const item = this.createSuggestionItem(suggestion);
fragment.appendChild(item);
});
} else {
const noResults = document.createElement('div');
noResults.className = 'no-results';
noResults.textContent = 'No suggestions found';
fragment.appendChild(noResults);
}
// Replace content efficiently
container.innerHTML = '';
container.appendChild(fragment);
container.classList.add('visible');
}
/**
* Create suggestion item element
*/
createSuggestionItem(suggestion) {
const item = document.createElement('div');
item.className = `suggestion-item suggestion-${suggestion.type}`;
const icon = this.getSuggestionIcon(suggestion.type);
const details = suggestion.operator ? `${suggestion.operator}` :
suggestion.park_count ? `${suggestion.park_count} parks` : '';
item.innerHTML = `
<span class="suggestion-icon">${icon}</span>
<span class="suggestion-name">${this.escapeHtml(suggestion.name)}</span>
<span class="suggestion-details">${details}</span>
`;
item.addEventListener('click', () => {
this.selectSuggestion(suggestion);
});
return item;
}
/**
* Get icon for suggestion type
*/
getSuggestionIcon(type) {
const icons = {
park: '🏰',
operator: '🏢',
location: '📍'
};
return icons[type] || '🔍';
}
/**
* Handle suggestion selection
*/
selectSuggestion(suggestion) {
const searchInput = document.querySelector('[data-autocomplete]');
if (searchInput) {
searchInput.value = suggestion.name;
// Trigger search or navigation
if (suggestion.url) {
window.location.href = suggestion.url;
} else {
// Trigger filter update
this.updateFilters({ search: suggestion.name });
}
}
this.hideSuggestions();
}
/**
* Hide suggestions dropdown
*/
hideSuggestions() {
const container = document.querySelector('[data-suggestions]');
if (container) {
container.classList.remove('visible');
}
}
/**
* Setup optimized filtering with minimal reflows
*/
setupOptimizedFiltering() {
const filterForm = document.querySelector('[data-filter-form]');
if (!filterForm) return;
// Debounce filter changes
filterForm.addEventListener('change', (e) => {
clearTimeout(this.filterTimeout);
this.filterTimeout = setTimeout(() => {
this.updateFilters();
}, 150);
});
}
/**
* Update filters using HTMX with loading states
*/
updateFilters(extraParams = {}) {
const form = document.querySelector('[data-filter-form]');
const resultsContainer = document.querySelector('[data-results]');
if (!form || !resultsContainer) return;
// Show loading state
resultsContainer.classList.add('loading');
const formData = new FormData(form);
// Add extra parameters
Object.entries(extraParams).forEach(([key, value]) => {
formData.set(key, value);
});
// Use HTMX for efficient partial updates
if (window.htmx) {
htmx.ajax('GET', form.action + '?' + new URLSearchParams(formData), {
target: '[data-results]',
swap: 'innerHTML'
}).then(() => {
resultsContainer.classList.remove('loading');
this.setupLazyLoading(); // Re-initialize for new content
this.updatePerformanceMetrics();
});
}
}
/**
* Setup progressive image loading with CloudFlare optimization
*/
setupProgressiveImageLoading() {
// Use CloudFlare's automatic image optimization
document.querySelectorAll('img[data-cf-image]').forEach(img => {
const imageId = img.dataset.cfImage;
const width = img.dataset.width || 400;
// Start with low quality
img.src = this.getCloudFlareImageUrl(imageId, width, 'low');
// Load high quality when in viewport
if (this.imageObserver) {
this.imageObserver.observe(img);
}
});
}
/**
* Get optimized CloudFlare image URL
*/
getCloudFlareImageUrl(imageId, width, quality = 'high') {
const baseUrl = window.CLOUDFLARE_IMAGES_BASE_URL || '/images';
const qualityMap = {
low: 20,
medium: 60,
high: 85
};
return `${baseUrl}/${imageId}/w=${width},quality=${qualityMap[quality]}`;
}
/**
* Setup performance monitoring
*/
setupPerformanceMonitoring() {
// Track page load performance
if ('performance' in window) {
window.addEventListener('load', () => {
setTimeout(() => {
this.reportPerformanceMetrics();
}, 100);
});
}
// Track user interactions
this.setupInteractionTracking();
}
/**
* Report performance metrics
*/
reportPerformanceMetrics() {
if (!('performance' in window)) return;
const navigation = performance.getEntriesByType('navigation')[0];
const paint = performance.getEntriesByType('paint');
const metrics = {
loadTime: navigation.loadEventEnd - navigation.loadEventStart,
domContentLoaded: navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart,
firstPaint: paint.find(p => p.name === 'first-paint')?.startTime || 0,
firstContentfulPaint: paint.find(p => p.name === 'first-contentful-paint')?.startTime || 0,
timestamp: Date.now(),
page: 'park-listing'
};
// Send metrics to analytics (if configured)
this.sendAnalytics('performance', metrics);
}
/**
* Setup interaction tracking for performance insights
*/
setupInteractionTracking() {
const startTime = performance.now();
['click', 'input', 'scroll'].forEach(eventType => {
document.addEventListener(eventType, (e) => {
this.trackInteraction(eventType, e.target, performance.now() - startTime);
}, { passive: true });
});
}
/**
* Track user interactions
*/
trackInteraction(type, target, time) {
// Throttle interaction tracking
if (!this.lastInteractionTime || time - this.lastInteractionTime > 100) {
this.lastInteractionTime = time;
const interaction = {
type,
element: target.tagName.toLowerCase(),
class: target.className,
time: Math.round(time),
page: 'park-listing'
};
this.sendAnalytics('interaction', interaction);
}
}
/**
* Send analytics data
*/
sendAnalytics(event, data) {
// Only send in production and if analytics is configured
if (window.ENABLE_ANALYTICS && navigator.sendBeacon) {
const payload = JSON.stringify({
event,
data,
timestamp: Date.now(),
url: window.location.pathname
});
navigator.sendBeacon('/api/analytics/', payload);
}
}
/**
* Update performance metrics display
*/
updatePerformanceMetrics() {
const metricsDisplay = document.querySelector('[data-performance-metrics]');
if (!metricsDisplay || !window.SHOW_DEBUG) return;
const imageCount = document.querySelectorAll('img').length;
const loadedImages = document.querySelectorAll('img.loaded').length;
const cacheHits = Object.keys(sessionStorage).filter(k => k.startsWith('search_')).length;
metricsDisplay.innerHTML = `
<div class="debug-metrics">
<span>Images: ${loadedImages}/${imageCount}</span>
<span>Cache hits: ${cacheHits}</span>
<span>Memory: ${this.getMemoryUsage()}MB</span>
</div>
`;
}
/**
* Get approximate memory usage
*/
getMemoryUsage() {
if ('memory' in performance) {
return Math.round(performance.memory.usedJSHeapSize / 1024 / 1024);
}
return 'N/A';
}
/**
* Handle keyboard navigation in search
*/
handleSearchKeyboard(e) {
const suggestions = document.querySelectorAll('.suggestion-item');
const active = document.querySelector('.suggestion-item.active');
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this.navigateSuggestions(suggestions, active, 1);
break;
case 'ArrowUp':
e.preventDefault();
this.navigateSuggestions(suggestions, active, -1);
break;
case 'Enter':
e.preventDefault();
if (active) {
active.click();
}
break;
case 'Escape':
this.hideSuggestions();
break;
}
}
/**
* Navigate through suggestions with keyboard
*/
navigateSuggestions(suggestions, active, direction) {
if (active) {
active.classList.remove('active');
}
let index = active ? Array.from(suggestions).indexOf(active) : -1;
index += direction;
if (index < 0) index = suggestions.length - 1;
if (index >= suggestions.length) index = 0;
if (suggestions[index]) {
suggestions[index].classList.add('active');
suggestions[index].scrollIntoView({ block: 'nearest' });
}
}
/**
* Utility function to escape HTML
*/
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Initialize performance optimizations when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
new ParkListingPerformance();
});
} else {
new ParkListingPerformance();
}
// Export for testing
if (typeof module !== 'undefined' && module.exports) {
module.exports = ParkListingPerformance;
}

View File

@@ -1,164 +0,0 @@
{% extends "core/search/layouts/filtered_list.html" %}
{% load static %}
{% block title %}Parks - ThrillWiki{% endblock %}
{% block list_actions %}
{# Simple View Toggle #}
<div class="flex items-center space-x-4">
<div class="flex items-center bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
<button hx-get="{% url 'parks:park_list' %}?view_mode=grid{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
hx-target="#results-container"
hx-push-url="true"
class="px-3 py-1 rounded-md text-sm font-medium transition-all {% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}bg-white dark:bg-gray-600 shadow-sm text-blue-600 dark:text-blue-400{% else %}text-gray-700 dark:text-gray-300{% endif %}">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
</button>
<button hx-get="{% url 'parks:park_list' %}?view_mode=list{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
hx-target="#results-container"
hx-push-url="true"
class="px-3 py-1 rounded-md text-sm font-medium transition-all {% if request.GET.view_mode == 'list' %}bg-white dark:bg-gray-600 shadow-sm text-blue-600 dark:text-blue-400{% else %}text-gray-700 dark:text-gray-300{% endif %}">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/>
</svg>
</button>
</div>
{% if user.is_authenticated %}
<a href="{% url 'parks:park_create' %}"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
Add Park
</a>
{% endif %}
</div>
{% endblock %}
{% block results_list %}
<div id="park-results"
class="overflow-hidden transition-all duration-300"
data-view-mode="{{ view_mode|default:'grid' }}">
{# Results Content with Adaptive Grid #}
<div class="p-6">
{% if parks %}
{# Enhanced Responsive Grid Container #}
<div class="{% if view_mode == 'list' %}space-y-4{% else %}grid gap-6 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4{% endif %} transition-all duration-300">
{% include "parks/partials/park_list_item.html" with parks=parks view_mode=view_mode|default:'grid' %}
</div>
{% else %}
{# Enhanced No Results Found Section #}
<div class="text-center py-16 px-4">
<div class="mx-auto w-32 h-32 text-gray-300 dark:text-gray-600 mb-8">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-full h-full">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
</svg>
</div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">No parks found</h3>
{% if request.GET.search %}
<p class="text-gray-600 dark:text-gray-400 mb-8 max-w-md mx-auto">
No parks match your search for <span class="font-semibold text-gray-900 dark:text-white">"{{ request.GET.search }}"</span>.
<br>Try searching with different keywords or check your spelling.
</p>
{% elif request.GET.park_type or request.GET.has_coasters or request.GET.min_rating or request.GET.big_parks_only %}
<p class="text-gray-600 dark:text-gray-400 mb-8 max-w-md mx-auto">
No parks match your current filter criteria.
<br>Try adjusting your filters or removing some restrictions.
</p>
{% else %}
<p class="text-gray-600 dark:text-gray-400 mb-8 max-w-md mx-auto">
No parks are currently available in the database.
</p>
{% endif %}
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
{% if request.GET.search or request.GET.park_type or request.GET.has_coasters or request.GET.min_rating or request.GET.big_parks_only %}
<button type="button"
onclick="clearAllFilters()"
class="inline-flex items-center px-6 py-3 border border-transparent text-sm font-semibold rounded-xl text-white bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-300 transform hover:scale-105">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Clear All Filters
</button>
<a href="{% url 'parks:park_list' %}"
class="inline-flex items-center px-6 py-3 text-sm font-semibold text-blue-700 dark:text-blue-300 hover:text-blue-800 dark:hover:text-blue-200 transition-colors duration-200">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
View all parks
</a>
{% endif %}
{% if user.is_authenticated %}
<a href="{% url 'parks:park_create' %}"
class="inline-flex items-center px-6 py-3 text-sm font-semibold text-green-700 dark:text-green-300 hover:text-green-800 dark:hover:text-green-200 transition-colors duration-200">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
Add a new park
</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
<script>
// Enhanced clear all filters function
function clearAllFilters() {
const form = document.getElementById('filter-form');
if (!form) return;
// Clear all form inputs
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
if (input.type === 'checkbox' || input.type === 'radio') {
input.checked = false;
} else if (input.type !== 'hidden') {
input.value = '';
}
});
// Clear search input as well
const searchInput = document.querySelector('input[name="search"]');
if (searchInput) {
searchInput.value = '';
}
// Submit form to reload without filters
htmx.trigger(form, 'submit');
}
// Smooth scroll to results after filter changes
document.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'results-container') {
// Add a subtle scroll to results with a small delay for content to settle
setTimeout(() => {
const resultsElement = document.getElementById('park-results');
if (resultsElement) {
resultsElement.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
});
}
}, 100);
// Flash effect to indicate content update
const resultsContainer = event.detail.target;
resultsContainer.style.opacity = '0.8';
setTimeout(() => {
resultsContainer.style.opacity = '1';
}, 150);
}
});
</script>
{% endblock %}

View File

@@ -1,36 +0,0 @@
{% load static %}
{% load cotton %}
{% if error %}
<div class="p-4" data-testid="park-list-error">
<div class="inline-flex items-center px-4 py-2 rounded-md bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 border border-red-200 dark:border-red-800">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
{{ error }}
</div>
</div>
{% else %}
{% for park in object_list|default:parks %}
<c-park_card park=park view_mode=view_mode />
{% empty %}
<div class="{% if view_mode == 'list' %}w-full{% else %}col-span-full{% endif %} p-12 text-center" data-testid="no-parks-found">
<div class="mx-auto w-24 h-24 text-gray-300 dark:text-gray-600 mb-6">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-full h-full">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">No parks found</h3>
<div class="text-gray-600 dark:text-gray-400">
{% if search_query %}
<p class="mb-4">No parks found matching "{{ search_query }}". Try adjusting your search terms.</p>
{% else %}
<p class="mb-4">No parks found matching your criteria. Try adjusting your filters.</p>
{% endif %}
{% if user.is_authenticated %}
<p>You can also <a href="{% url 'parks:park_create' %}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 font-semibold">add a new park</a>.</p>
{% endif %}
</div>
</div>
{% endfor %}
{% endif %}

View File

@@ -1,178 +0,0 @@
"""
Park search autocomplete views for enhanced search functionality.
Provides fast, cached autocomplete suggestions for park search.
"""
from typing import Dict, List, Any
from django.http import JsonResponse
from django.views import View
from django.core.cache import cache
from django.db.models import Q
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from .models import Park
from .models.companies import Company
from .services.filter_service import ParkFilterService
class ParkAutocompleteView(View):
"""
Provides autocomplete suggestions for park search.
Returns JSON with park names, operators, and location suggestions.
"""
def get(self, request):
"""Handle GET request for autocomplete suggestions."""
query = request.GET.get('q', '').strip()
if len(query) < 2:
return JsonResponse({
'suggestions': [],
'message': 'Type at least 2 characters to search'
})
# Check cache first
cache_key = f"park_autocomplete:{query.lower()}"
cached_result = cache.get(cache_key)
if cached_result:
return JsonResponse(cached_result)
# Generate suggestions
suggestions = self._get_suggestions(query)
# Cache results for 5 minutes
result = {
'suggestions': suggestions,
'query': query
}
cache.set(cache_key, result, 300)
return JsonResponse(result)
def _get_suggestions(self, query: str) -> List[Dict[str, Any]]:
"""Generate autocomplete suggestions based on query."""
suggestions = []
# Park name suggestions (top 5)
park_suggestions = self._get_park_suggestions(query)
suggestions.extend(park_suggestions)
# Operator suggestions (top 3)
operator_suggestions = self._get_operator_suggestions(query)
suggestions.extend(operator_suggestions)
# Location suggestions (top 3)
location_suggestions = self._get_location_suggestions(query)
suggestions.extend(location_suggestions)
# Remove duplicates and limit results
seen = set()
unique_suggestions = []
for suggestion in suggestions:
key = suggestion['name'].lower()
if key not in seen:
seen.add(key)
unique_suggestions.append(suggestion)
return unique_suggestions[:10] # Limit to 10 suggestions
def _get_park_suggestions(self, query: str) -> List[Dict[str, Any]]:
"""Get park name suggestions."""
parks = Park.objects.filter(
name__icontains=query,
status='OPERATING'
).select_related('operator').order_by('name')[:5]
suggestions = []
for park in parks:
suggestion = {
'name': park.name,
'type': 'park',
'operator': park.operator.name if park.operator else None,
'url': f'/parks/{park.slug}/' if park.slug else None
}
suggestions.append(suggestion)
return suggestions
def _get_operator_suggestions(self, query: str) -> List[Dict[str, Any]]:
"""Get operator suggestions."""
operators = Company.objects.filter(
roles__contains=['OPERATOR'],
name__icontains=query
).order_by('name')[:3]
suggestions = []
for operator in operators:
suggestion = {
'name': operator.name,
'type': 'operator',
'park_count': operator.operated_parks.filter(status='OPERATING').count()
}
suggestions.append(suggestion)
return suggestions
def _get_location_suggestions(self, query: str) -> List[Dict[str, Any]]:
"""Get location (city/country) suggestions."""
# Get unique cities
city_parks = Park.objects.filter(
location__city__icontains=query,
status='OPERATING'
).select_related('location').order_by('location__city').distinct()[:2]
# Get unique countries
country_parks = Park.objects.filter(
location__country__icontains=query,
status='OPERATING'
).select_related('location').order_by('location__country').distinct()[:2]
suggestions = []
# Add city suggestions
for park in city_parks:
if park.location and park.location.city:
city_name = park.location.city
if park.location.country:
city_name += f", {park.location.country}"
suggestion = {
'name': city_name,
'type': 'location',
'location_type': 'city'
}
suggestions.append(suggestion)
# Add country suggestions
for park in country_parks:
if park.location and park.location.country:
suggestion = {
'name': park.location.country,
'type': 'location',
'location_type': 'country'
}
suggestions.append(suggestion)
return suggestions
@method_decorator(cache_page(60 * 5), name='dispatch') # Cache for 5 minutes
class QuickFilterSuggestionsView(View):
"""
Provides quick filter suggestions and popular filters.
Used for search dropdown quick actions.
"""
def get(self, request):
"""Handle GET request for quick filter suggestions."""
filter_service = ParkFilterService()
popular_filters = filter_service.get_popular_filters()
filter_counts = filter_service.get_filter_counts()
return JsonResponse({
'quick_filters': popular_filters.get('quick_filters', []),
'filter_counts': filter_counts,
'recommended_sorts': popular_filters.get('recommended_sorts', [])
})

View File

@@ -1,12 +0,0 @@
"""
Rides Django App
This app handles all ride-related functionality including ride models,
companies, rankings, and search functionality.
"""
# Import choices to ensure they are registered with the global registry
from . import choices
# Ensure choices are registered on app startup
__all__ = ['choices']

View File

@@ -1,804 +0,0 @@
"""
Rich Choice Objects for Rides Domain
This module defines all choice objects for the rides domain, replacing
the legacy tuple-based choices with rich choice objects.
"""
from apps.core.choices import RichChoice, ChoiceCategory
from apps.core.choices.registry import register_choices
# Ride Category Choices
RIDE_CATEGORIES = [
RichChoice(
value="RC",
label="Roller Coaster",
description="Thrill rides with tracks featuring hills, loops, and high speeds",
metadata={
'color': 'red',
'icon': 'roller-coaster',
'css_class': 'bg-red-100 text-red-800',
'sort_order': 1
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="DR",
label="Dark Ride",
description="Indoor rides with themed environments and storytelling",
metadata={
'color': 'purple',
'icon': 'dark-ride',
'css_class': 'bg-purple-100 text-purple-800',
'sort_order': 2
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="FR",
label="Flat Ride",
description="Rides that move along a generally flat plane with spinning, swinging, or bouncing motions",
metadata={
'color': 'blue',
'icon': 'flat-ride',
'css_class': 'bg-blue-100 text-blue-800',
'sort_order': 3
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="WR",
label="Water Ride",
description="Rides that incorporate water elements like splashing, floating, or getting wet",
metadata={
'color': 'cyan',
'icon': 'water-ride',
'css_class': 'bg-cyan-100 text-cyan-800',
'sort_order': 4
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="TR",
label="Transport Ride",
description="Rides primarily designed for transportation around the park",
metadata={
'color': 'green',
'icon': 'transport',
'css_class': 'bg-green-100 text-green-800',
'sort_order': 5
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="OT",
label="Other",
description="Rides that don't fit into standard categories",
metadata={
'color': 'gray',
'icon': 'other',
'css_class': 'bg-gray-100 text-gray-800',
'sort_order': 6
},
category=ChoiceCategory.CLASSIFICATION
),
]
# Ride Status Choices
RIDE_STATUSES = [
RichChoice(
value="OPERATING",
label="Operating",
description="Ride is currently open and operating normally",
metadata={
'color': 'green',
'icon': 'check-circle',
'css_class': 'bg-green-100 text-green-800',
'sort_order': 1
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="CLOSED_TEMP",
label="Temporarily Closed",
description="Ride is temporarily closed for maintenance, weather, or other short-term reasons",
metadata={
'color': 'yellow',
'icon': 'pause-circle',
'css_class': 'bg-yellow-100 text-yellow-800',
'sort_order': 2
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="SBNO",
label="Standing But Not Operating",
description="Ride structure remains but is not currently operating",
metadata={
'color': 'orange',
'icon': 'stop-circle',
'css_class': 'bg-orange-100 text-orange-800',
'sort_order': 3
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="CLOSING",
label="Closing",
description="Ride is scheduled to close permanently",
metadata={
'color': 'red',
'icon': 'x-circle',
'css_class': 'bg-red-100 text-red-800',
'sort_order': 4
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="CLOSED_PERM",
label="Permanently Closed",
description="Ride has been permanently closed and will not reopen",
metadata={
'color': 'red',
'icon': 'x-circle',
'css_class': 'bg-red-100 text-red-800',
'sort_order': 5
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="UNDER_CONSTRUCTION",
label="Under Construction",
description="Ride is currently being built or undergoing major renovation",
metadata={
'color': 'blue',
'icon': 'tool',
'css_class': 'bg-blue-100 text-blue-800',
'sort_order': 6
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="DEMOLISHED",
label="Demolished",
description="Ride has been completely removed and demolished",
metadata={
'color': 'gray',
'icon': 'trash',
'css_class': 'bg-gray-100 text-gray-800',
'sort_order': 7
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="RELOCATED",
label="Relocated",
description="Ride has been moved to a different location",
metadata={
'color': 'purple',
'icon': 'arrow-right',
'css_class': 'bg-purple-100 text-purple-800',
'sort_order': 8
},
category=ChoiceCategory.STATUS
),
]
# Post-Closing Status Choices
POST_CLOSING_STATUSES = [
RichChoice(
value="SBNO",
label="Standing But Not Operating",
description="Ride structure remains but is not operating after closure",
metadata={
'color': 'orange',
'icon': 'stop-circle',
'css_class': 'bg-orange-100 text-orange-800',
'sort_order': 1
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="CLOSED_PERM",
label="Permanently Closed",
description="Ride has been permanently closed after the closing date",
metadata={
'color': 'red',
'icon': 'x-circle',
'css_class': 'bg-red-100 text-red-800',
'sort_order': 2
},
category=ChoiceCategory.STATUS
),
]
# Roller Coaster Track Material Choices
TRACK_MATERIALS = [
RichChoice(
value="STEEL",
label="Steel",
description="Modern steel track construction providing smooth rides and complex layouts",
metadata={
'color': 'gray',
'icon': 'steel',
'css_class': 'bg-gray-100 text-gray-800',
'sort_order': 1
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="WOOD",
label="Wood",
description="Traditional wooden track construction providing classic coaster experience",
metadata={
'color': 'amber',
'icon': 'wood',
'css_class': 'bg-amber-100 text-amber-800',
'sort_order': 2
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="HYBRID",
label="Hybrid",
description="Combination of steel and wooden construction elements",
metadata={
'color': 'orange',
'icon': 'hybrid',
'css_class': 'bg-orange-100 text-orange-800',
'sort_order': 3
},
category=ChoiceCategory.TECHNICAL
),
]
# Roller Coaster Type Choices
COASTER_TYPES = [
RichChoice(
value="SITDOWN",
label="Sit Down",
description="Traditional seated roller coaster with riders sitting upright",
metadata={
'color': 'blue',
'icon': 'sitdown',
'css_class': 'bg-blue-100 text-blue-800',
'sort_order': 1
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="INVERTED",
label="Inverted",
description="Coaster where riders' feet dangle freely below the track",
metadata={
'color': 'purple',
'icon': 'inverted',
'css_class': 'bg-purple-100 text-purple-800',
'sort_order': 2
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="FLYING",
label="Flying",
description="Riders lie face-down in a flying position",
metadata={
'color': 'sky',
'icon': 'flying',
'css_class': 'bg-sky-100 text-sky-800',
'sort_order': 3
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="STANDUP",
label="Stand Up",
description="Riders stand upright during the ride",
metadata={
'color': 'green',
'icon': 'standup',
'css_class': 'bg-green-100 text-green-800',
'sort_order': 4
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="WING",
label="Wing",
description="Riders sit on either side of the track with nothing above or below",
metadata={
'color': 'indigo',
'icon': 'wing',
'css_class': 'bg-indigo-100 text-indigo-800',
'sort_order': 5
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="DIVE",
label="Dive",
description="Features a vertical or near-vertical drop as the main element",
metadata={
'color': 'red',
'icon': 'dive',
'css_class': 'bg-red-100 text-red-800',
'sort_order': 6
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="FAMILY",
label="Family",
description="Designed for riders of all ages with moderate thrills",
metadata={
'color': 'emerald',
'icon': 'family',
'css_class': 'bg-emerald-100 text-emerald-800',
'sort_order': 7
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="WILD_MOUSE",
label="Wild Mouse",
description="Compact coaster with sharp turns and sudden drops",
metadata={
'color': 'yellow',
'icon': 'mouse',
'css_class': 'bg-yellow-100 text-yellow-800',
'sort_order': 8
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="SPINNING",
label="Spinning",
description="Cars rotate freely during the ride",
metadata={
'color': 'pink',
'icon': 'spinning',
'css_class': 'bg-pink-100 text-pink-800',
'sort_order': 9
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="FOURTH_DIMENSION",
label="4th Dimension",
description="Seats rotate independently of the track direction",
metadata={
'color': 'violet',
'icon': '4d',
'css_class': 'bg-violet-100 text-violet-800',
'sort_order': 10
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="OTHER",
label="Other",
description="Coaster type that doesn't fit standard classifications",
metadata={
'color': 'gray',
'icon': 'other',
'css_class': 'bg-gray-100 text-gray-800',
'sort_order': 11
},
category=ChoiceCategory.CLASSIFICATION
),
]
# Propulsion System Choices
PROPULSION_SYSTEMS = [
RichChoice(
value="CHAIN",
label="Chain Lift",
description="Traditional chain lift system to pull trains up the lift hill",
metadata={
'color': 'gray',
'icon': 'chain',
'css_class': 'bg-gray-100 text-gray-800',
'sort_order': 1
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="LSM",
label="LSM Launch",
description="Linear Synchronous Motor launch system using magnetic propulsion",
metadata={
'color': 'blue',
'icon': 'lightning',
'css_class': 'bg-blue-100 text-blue-800',
'sort_order': 2
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="HYDRAULIC",
label="Hydraulic Launch",
description="High-pressure hydraulic launch system for rapid acceleration",
metadata={
'color': 'red',
'icon': 'hydraulic',
'css_class': 'bg-red-100 text-red-800',
'sort_order': 3
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="GRAVITY",
label="Gravity",
description="Uses gravity and momentum without mechanical lift systems",
metadata={
'color': 'green',
'icon': 'gravity',
'css_class': 'bg-green-100 text-green-800',
'sort_order': 4
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="OTHER",
label="Other",
description="Propulsion system that doesn't fit standard categories",
metadata={
'color': 'gray',
'icon': 'other',
'css_class': 'bg-gray-100 text-gray-800',
'sort_order': 5
},
category=ChoiceCategory.TECHNICAL
),
]
# Ride Model Target Market Choices
TARGET_MARKETS = [
RichChoice(
value="FAMILY",
label="Family",
description="Designed for families with children, moderate thrills",
metadata={
'color': 'green',
'icon': 'family',
'css_class': 'bg-green-100 text-green-800',
'sort_order': 1
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="THRILL",
label="Thrill",
description="High-intensity rides for thrill seekers",
metadata={
'color': 'red',
'icon': 'thrill',
'css_class': 'bg-red-100 text-red-800',
'sort_order': 2
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="EXTREME",
label="Extreme",
description="Maximum intensity rides for extreme thrill seekers",
metadata={
'color': 'purple',
'icon': 'extreme',
'css_class': 'bg-purple-100 text-purple-800',
'sort_order': 3
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="KIDDIE",
label="Kiddie",
description="Gentle rides designed specifically for young children",
metadata={
'color': 'yellow',
'icon': 'kiddie',
'css_class': 'bg-yellow-100 text-yellow-800',
'sort_order': 4
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="ALL_AGES",
label="All Ages",
description="Suitable for riders of all ages and thrill preferences",
metadata={
'color': 'blue',
'icon': 'all-ages',
'css_class': 'bg-blue-100 text-blue-800',
'sort_order': 5
},
category=ChoiceCategory.CLASSIFICATION
),
]
# Ride Model Photo Type Choices
PHOTO_TYPES = [
RichChoice(
value="PROMOTIONAL",
label="Promotional",
description="Marketing and promotional photos of the ride model",
metadata={
'color': 'blue',
'icon': 'camera',
'css_class': 'bg-blue-100 text-blue-800',
'sort_order': 1
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="TECHNICAL",
label="Technical Drawing",
description="Technical drawings and engineering diagrams",
metadata={
'color': 'gray',
'icon': 'blueprint',
'css_class': 'bg-gray-100 text-gray-800',
'sort_order': 2
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="INSTALLATION",
label="Installation Example",
description="Photos of actual installations of this ride model",
metadata={
'color': 'green',
'icon': 'installation',
'css_class': 'bg-green-100 text-green-800',
'sort_order': 3
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="RENDERING",
label="3D Rendering",
description="Computer-generated 3D renderings of the ride model",
metadata={
'color': 'purple',
'icon': 'cube',
'css_class': 'bg-purple-100 text-purple-800',
'sort_order': 4
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="CATALOG",
label="Catalog Image",
description="Official catalog and brochure images",
metadata={
'color': 'orange',
'icon': 'catalog',
'css_class': 'bg-orange-100 text-orange-800',
'sort_order': 5
},
category=ChoiceCategory.CLASSIFICATION
),
]
# Technical Specification Category Choices
SPEC_CATEGORIES = [
RichChoice(
value="DIMENSIONS",
label="Dimensions",
description="Physical dimensions and measurements",
metadata={
'color': 'blue',
'icon': 'ruler',
'css_class': 'bg-blue-100 text-blue-800',
'sort_order': 1
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="PERFORMANCE",
label="Performance",
description="Performance specifications and capabilities",
metadata={
'color': 'red',
'icon': 'speedometer',
'css_class': 'bg-red-100 text-red-800',
'sort_order': 2
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="CAPACITY",
label="Capacity",
description="Rider capacity and throughput specifications",
metadata={
'color': 'green',
'icon': 'users',
'css_class': 'bg-green-100 text-green-800',
'sort_order': 3
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="SAFETY",
label="Safety Features",
description="Safety systems and features",
metadata={
'color': 'yellow',
'icon': 'shield',
'css_class': 'bg-yellow-100 text-yellow-800',
'sort_order': 4
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="ELECTRICAL",
label="Electrical Requirements",
description="Power and electrical system requirements",
metadata={
'color': 'purple',
'icon': 'lightning',
'css_class': 'bg-purple-100 text-purple-800',
'sort_order': 5
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="FOUNDATION",
label="Foundation Requirements",
description="Foundation and structural requirements",
metadata={
'color': 'gray',
'icon': 'foundation',
'css_class': 'bg-gray-100 text-gray-800',
'sort_order': 6
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="MAINTENANCE",
label="Maintenance",
description="Maintenance requirements and procedures",
metadata={
'color': 'orange',
'icon': 'wrench',
'css_class': 'bg-orange-100 text-orange-800',
'sort_order': 7
},
category=ChoiceCategory.TECHNICAL
),
RichChoice(
value="OTHER",
label="Other",
description="Other technical specifications",
metadata={
'color': 'gray',
'icon': 'other',
'css_class': 'bg-gray-100 text-gray-800',
'sort_order': 8
},
category=ChoiceCategory.TECHNICAL
),
]
# Company Role Choices for Rides Domain (MANUFACTURER and DESIGNER only)
RIDES_COMPANY_ROLES = [
RichChoice(
value="MANUFACTURER",
label="Ride Manufacturer",
description="Company that designs and builds ride hardware and systems",
metadata={
'color': 'blue',
'icon': 'factory',
'css_class': 'bg-blue-100 text-blue-800',
'sort_order': 1,
'domain': 'rides',
'permissions': ['manage_ride_models', 'view_manufacturing'],
'url_pattern': '/rides/manufacturers/{slug}/'
},
category=ChoiceCategory.CLASSIFICATION
),
RichChoice(
value="DESIGNER",
label="Ride Designer",
description="Company that specializes in ride design, layout, and engineering",
metadata={
'color': 'purple',
'icon': 'design',
'css_class': 'bg-purple-100 text-purple-800',
'sort_order': 2,
'domain': 'rides',
'permissions': ['manage_ride_designs', 'view_design_specs'],
'url_pattern': '/rides/designers/{slug}/'
},
category=ChoiceCategory.CLASSIFICATION
),
]
def register_rides_choices():
"""Register all rides domain choices with the global registry"""
register_choices(
name="categories",
choices=RIDE_CATEGORIES,
domain="rides",
description="Ride category classifications",
metadata={'domain': 'rides', 'type': 'category'}
)
register_choices(
name="statuses",
choices=RIDE_STATUSES,
domain="rides",
description="Ride operational status options",
metadata={'domain': 'rides', 'type': 'status'}
)
register_choices(
name="post_closing_statuses",
choices=POST_CLOSING_STATUSES,
domain="rides",
description="Status options after ride closure",
metadata={'domain': 'rides', 'type': 'post_closing_status'}
)
register_choices(
name="track_materials",
choices=TRACK_MATERIALS,
domain="rides",
description="Roller coaster track material types",
metadata={'domain': 'rides', 'type': 'track_material', 'applies_to': 'roller_coasters'}
)
register_choices(
name="coaster_types",
choices=COASTER_TYPES,
domain="rides",
description="Roller coaster type classifications",
metadata={'domain': 'rides', 'type': 'coaster_type', 'applies_to': 'roller_coasters'}
)
register_choices(
name="propulsion_systems",
choices=PROPULSION_SYSTEMS,
domain="rides",
description="Roller coaster propulsion and lift systems",
metadata={'domain': 'rides', 'type': 'propulsion_system', 'applies_to': 'roller_coasters'}
)
register_choices(
name="target_markets",
choices=TARGET_MARKETS,
domain="rides",
description="Target market classifications for ride models",
metadata={'domain': 'rides', 'type': 'target_market', 'applies_to': 'ride_models'}
)
register_choices(
name="photo_types",
choices=PHOTO_TYPES,
domain="rides",
description="Photo type classifications for ride model images",
metadata={'domain': 'rides', 'type': 'photo_type', 'applies_to': 'ride_model_photos'}
)
register_choices(
name="spec_categories",
choices=SPEC_CATEGORIES,
domain="rides",
description="Technical specification category classifications",
metadata={'domain': 'rides', 'type': 'spec_category', 'applies_to': 'ride_model_specs'}
)
register_choices(
name="company_roles",
choices=RIDES_COMPANY_ROLES,
domain="rides",
description="Company role classifications for rides domain (MANUFACTURER and DESIGNER only)",
metadata={'domain': 'rides', 'type': 'company_role'}
)
# Auto-register choices when module is imported
register_rides_choices()

File diff suppressed because it is too large Load Diff

View File

@@ -1,898 +0,0 @@
from django.db import models
from django.utils.text import slugify
from config.django import base as settings
from apps.core.models import TrackedModel
from apps.core.choices import RichChoiceField
from .company import Company
import pghistory
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .rides import RollerCoasterStats
@pghistory.track()
class RideModel(TrackedModel):
"""
Represents a specific model/type of ride that can be manufactured by different
companies. This serves as a catalog of ride designs that can be referenced
by individual ride installations.
For example: B&M Dive Coaster, Vekoma Boomerang, RMC I-Box, etc.
"""
name = models.CharField(max_length=255, help_text="Name of the ride model")
slug = models.SlugField(
max_length=255, help_text="URL-friendly identifier (unique within manufacturer)"
)
manufacturer = models.ForeignKey(
Company,
on_delete=models.SET_NULL,
related_name="ride_models",
null=True,
blank=True,
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
help_text="Primary manufacturer of this ride model",
)
description = models.TextField(
blank=True, help_text="Detailed description of the ride model"
)
category = RichChoiceField(
choice_group="categories",
domain="rides",
max_length=2,
default="",
blank=True,
help_text="Primary category classification",
)
# Technical specifications
typical_height_range_min_ft = models.DecimalField(
max_digits=6,
decimal_places=2,
null=True,
blank=True,
help_text="Minimum typical height in feet for this model",
)
typical_height_range_max_ft = models.DecimalField(
max_digits=6,
decimal_places=2,
null=True,
blank=True,
help_text="Maximum typical height in feet for this model",
)
typical_speed_range_min_mph = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True,
help_text="Minimum typical speed in mph for this model",
)
typical_speed_range_max_mph = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True,
help_text="Maximum typical speed in mph for this model",
)
typical_capacity_range_min = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Minimum typical hourly capacity for this model",
)
typical_capacity_range_max = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Maximum typical hourly capacity for this model",
)
# Design characteristics
track_type = models.CharField(
max_length=100,
blank=True,
help_text="Type of track system (e.g., tubular steel, I-Box, wooden)",
)
support_structure = models.CharField(
max_length=100,
blank=True,
help_text="Type of support structure (e.g., steel, wooden, hybrid)",
)
train_configuration = models.CharField(
max_length=200,
blank=True,
help_text="Typical train configuration (e.g., 2 trains, 7 cars per train, 4 seats per car)",
)
restraint_system = models.CharField(
max_length=100,
blank=True,
help_text="Type of restraint system (e.g., over-shoulder, lap bar, vest)",
)
# Market information
first_installation_year = models.PositiveIntegerField(
null=True, blank=True, help_text="Year of first installation of this model"
)
last_installation_year = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Year of last installation of this model (if discontinued)",
)
is_discontinued = models.BooleanField(
default=False, help_text="Whether this model is no longer being manufactured"
)
total_installations = models.PositiveIntegerField(
default=0, help_text="Total number of installations worldwide (auto-calculated)"
)
# Design features
notable_features = models.TextField(
blank=True,
help_text="Notable design features or innovations (JSON or comma-separated)",
)
target_market = RichChoiceField(
choice_group="target_markets",
domain="rides",
max_length=50,
blank=True,
help_text="Primary target market for this ride model",
)
# Media
primary_image = models.ForeignKey(
"RideModelPhoto",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="ride_models_as_primary",
help_text="Primary promotional image for this ride model",
)
# SEO and metadata
meta_title = models.CharField(
max_length=60, blank=True, help_text="SEO meta title (auto-generated if blank)"
)
meta_description = models.CharField(
max_length=160,
blank=True,
help_text="SEO meta description (auto-generated if blank)",
)
# Frontend URL
url = models.URLField(blank=True, help_text="Frontend URL for this ride model")
class Meta(TrackedModel.Meta):
ordering = ["manufacturer__name", "name"]
unique_together = [["manufacturer", "name"], ["manufacturer", "slug"]]
constraints = [
# Height range validation
models.CheckConstraint(
name="ride_model_height_range_logical",
condition=models.Q(typical_height_range_min_ft__isnull=True)
| models.Q(typical_height_range_max_ft__isnull=True)
| models.Q(
typical_height_range_min_ft__lte=models.F(
"typical_height_range_max_ft"
)
),
violation_error_message="Minimum height cannot exceed maximum height",
),
# Speed range validation
models.CheckConstraint(
name="ride_model_speed_range_logical",
condition=models.Q(typical_speed_range_min_mph__isnull=True)
| models.Q(typical_speed_range_max_mph__isnull=True)
| models.Q(
typical_speed_range_min_mph__lte=models.F(
"typical_speed_range_max_mph"
)
),
violation_error_message="Minimum speed cannot exceed maximum speed",
),
# Capacity range validation
models.CheckConstraint(
name="ride_model_capacity_range_logical",
condition=models.Q(typical_capacity_range_min__isnull=True)
| models.Q(typical_capacity_range_max__isnull=True)
| models.Q(
typical_capacity_range_min__lte=models.F(
"typical_capacity_range_max"
)
),
violation_error_message="Minimum capacity cannot exceed maximum capacity",
),
# Installation years validation
models.CheckConstraint(
name="ride_model_installation_years_logical",
condition=models.Q(first_installation_year__isnull=True)
| models.Q(last_installation_year__isnull=True)
| models.Q(
first_installation_year__lte=models.F("last_installation_year")
),
violation_error_message="First installation year cannot be after last installation year",
),
]
def __str__(self) -> str:
return (
self.name
if not self.manufacturer
else f"{self.manufacturer.name} {self.name}"
)
def save(self, *args, **kwargs) -> None:
if not self.slug:
from django.utils.text import slugify
# Only use the ride model name for the slug, not manufacturer
base_slug = slugify(self.name)
self.slug = base_slug
# Ensure uniqueness within the same manufacturer
counter = 1
while (
RideModel.objects.filter(manufacturer=self.manufacturer, slug=self.slug)
.exclude(pk=self.pk)
.exists()
):
self.slug = f"{base_slug}-{counter}"
counter += 1
# Auto-generate meta fields if blank
if not self.meta_title:
self.meta_title = str(self)[:60]
if not self.meta_description:
desc = (
f"{self} - {self.description[:100]}" if self.description else str(self)
)
self.meta_description = desc[:160]
# Generate frontend URL
if self.manufacturer:
frontend_domain = getattr(
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
)
self.url = f"{frontend_domain}/rides/manufacturers/{self.manufacturer.slug}/{self.slug}/"
super().save(*args, **kwargs)
def update_installation_count(self) -> None:
"""Update the total installations count based on actual ride instances."""
# Import here to avoid circular import
from django.apps import apps
Ride = apps.get_model("rides", "Ride")
self.total_installations = Ride.objects.filter(ride_model=self).count()
self.save(update_fields=["total_installations"])
@property
def installation_years_range(self) -> str:
"""Get a formatted string of installation years range."""
if self.first_installation_year and self.last_installation_year:
return f"{self.first_installation_year}-{self.last_installation_year}"
elif self.first_installation_year:
return (
f"{self.first_installation_year}-present"
if not self.is_discontinued
else f"{self.first_installation_year}+"
)
return "Unknown"
@property
def height_range_display(self) -> str:
"""Get a formatted string of height range."""
if self.typical_height_range_min_ft and self.typical_height_range_max_ft:
return f"{self.typical_height_range_min_ft}-{self.typical_height_range_max_ft} ft"
elif self.typical_height_range_min_ft:
return f"{self.typical_height_range_min_ft}+ ft"
elif self.typical_height_range_max_ft:
return f"Up to {self.typical_height_range_max_ft} ft"
return "Variable"
@property
def speed_range_display(self) -> str:
"""Get a formatted string of speed range."""
if self.typical_speed_range_min_mph and self.typical_speed_range_max_mph:
return f"{self.typical_speed_range_min_mph}-{self.typical_speed_range_max_mph} mph"
elif self.typical_speed_range_min_mph:
return f"{self.typical_speed_range_min_mph}+ mph"
elif self.typical_speed_range_max_mph:
return f"Up to {self.typical_speed_range_max_mph} mph"
return "Variable"
@pghistory.track()
class RideModelVariant(TrackedModel):
"""
Represents specific variants or configurations of a ride model.
For example: B&M Hyper Coaster might have variants like "Mega Coaster", "Giga Coaster"
"""
ride_model = models.ForeignKey(
RideModel, on_delete=models.CASCADE, related_name="variants"
)
name = models.CharField(max_length=255, help_text="Name of this variant")
description = models.TextField(
blank=True, help_text="Description of variant differences"
)
# Variant-specific specifications
min_height_ft = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True
)
max_height_ft = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True
)
min_speed_mph = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True
)
max_speed_mph = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True
)
# Distinguishing features
distinguishing_features = models.TextField(
blank=True, help_text="What makes this variant unique from the base model"
)
class Meta(TrackedModel.Meta):
ordering = ["ride_model", "name"]
unique_together = ["ride_model", "name"]
def __str__(self) -> str:
return f"{self.ride_model} - {self.name}"
@pghistory.track()
class RideModelPhoto(TrackedModel):
"""Photos associated with ride models for catalog/promotional purposes."""
ride_model = models.ForeignKey(
RideModel, on_delete=models.CASCADE, related_name="photos"
)
image = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
on_delete=models.CASCADE,
help_text="Photo of the ride model stored on Cloudflare Images"
)
caption = models.CharField(max_length=500, blank=True)
alt_text = models.CharField(max_length=255, blank=True)
# Photo metadata
photo_type = RichChoiceField(
choice_group="photo_types",
domain="rides",
max_length=20,
default="PROMOTIONAL",
help_text="Type of photo for categorization and display purposes",
)
is_primary = models.BooleanField(
default=False, help_text="Whether this is the primary photo for the ride model"
)
# Attribution
photographer = models.CharField(max_length=255, blank=True)
source = models.CharField(max_length=255, blank=True)
copyright_info = models.CharField(max_length=255, blank=True)
class Meta(TrackedModel.Meta):
ordering = ["-is_primary", "-created_at"]
def __str__(self) -> str:
return f"Photo of {self.ride_model.name}"
def save(self, *args, **kwargs) -> None:
# Ensure only one primary photo per ride model
if self.is_primary:
RideModelPhoto.objects.filter(
ride_model=self.ride_model, is_primary=True
).exclude(pk=self.pk).update(is_primary=False)
super().save(*args, **kwargs)
@pghistory.track()
class RideModelTechnicalSpec(TrackedModel):
"""
Technical specifications for ride models that don't fit in the main model.
This allows for flexible specification storage.
"""
ride_model = models.ForeignKey(
RideModel, on_delete=models.CASCADE, related_name="technical_specs"
)
spec_category = RichChoiceField(
choice_group="spec_categories",
domain="rides",
max_length=50,
help_text="Category of technical specification",
)
spec_name = models.CharField(max_length=100, help_text="Name of the specification")
spec_value = models.CharField(
max_length=255, help_text="Value of the specification"
)
spec_unit = models.CharField(
max_length=20, blank=True, help_text="Unit of measurement"
)
notes = models.TextField(
blank=True, help_text="Additional notes about this specification"
)
class Meta(TrackedModel.Meta):
ordering = ["spec_category", "spec_name"]
unique_together = ["ride_model", "spec_category", "spec_name"]
def __str__(self) -> str:
unit_str = f" {self.spec_unit}" if self.spec_unit else ""
return f"{self.ride_model.name} - {self.spec_name}: {self.spec_value}{unit_str}"
@pghistory.track()
class Ride(TrackedModel):
"""Model for individual ride installations at parks
Note: The average_rating field is denormalized and refreshed by background
jobs. Use selectors or annotations for real-time calculations if needed.
"""
if TYPE_CHECKING:
coaster_stats: 'RollerCoasterStats'
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255)
description = models.TextField(blank=True)
park = models.ForeignKey(
"parks.Park", on_delete=models.CASCADE, related_name="rides"
)
park_area = models.ForeignKey(
"parks.ParkArea",
on_delete=models.SET_NULL,
related_name="rides",
null=True,
blank=True,
)
category = RichChoiceField(
choice_group="categories",
domain="rides",
max_length=2,
default="",
blank=True,
help_text="Ride category classification"
)
manufacturer = models.ForeignKey(
Company,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="manufactured_rides",
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
)
designer = models.ForeignKey(
Company,
on_delete=models.SET_NULL,
related_name="designed_rides",
null=True,
blank=True,
limit_choices_to={"roles__contains": ["DESIGNER"]},
)
ride_model = models.ForeignKey(
"RideModel",
on_delete=models.SET_NULL,
related_name="rides",
null=True,
blank=True,
help_text="The specific model/type of this ride",
)
status = RichChoiceField(
choice_group="statuses",
domain="rides",
max_length=20,
default="OPERATING",
help_text="Current operational status of the ride"
)
post_closing_status = RichChoiceField(
choice_group="post_closing_statuses",
domain="rides",
max_length=20,
null=True,
blank=True,
help_text="Status to change to after closing date",
)
opening_date = models.DateField(null=True, blank=True)
closing_date = models.DateField(null=True, blank=True)
status_since = models.DateField(null=True, blank=True)
min_height_in = models.PositiveIntegerField(null=True, blank=True)
max_height_in = models.PositiveIntegerField(null=True, blank=True)
capacity_per_hour = models.PositiveIntegerField(null=True, blank=True)
ride_duration_seconds = models.PositiveIntegerField(null=True, blank=True)
average_rating = models.DecimalField(
max_digits=3, decimal_places=2, null=True, blank=True
)
# Computed fields for hybrid filtering
opening_year = models.IntegerField(null=True, blank=True, db_index=True)
search_text = models.TextField(blank=True, db_index=True)
# Image settings - references to existing photos
banner_image = models.ForeignKey(
"RidePhoto",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="rides_using_as_banner",
help_text="Photo to use as banner image for this ride",
)
card_image = models.ForeignKey(
"RidePhoto",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="rides_using_as_card",
help_text="Photo to use as card image for this ride",
)
# Frontend URL
url = models.URLField(blank=True, help_text="Frontend URL for this ride")
park_url = models.URLField(
blank=True, help_text="Frontend URL for this ride's park"
)
class Meta(TrackedModel.Meta):
ordering = ["name"]
unique_together = ["park", "slug"]
constraints = [
# Business rule: Closing date must be after opening date
models.CheckConstraint(
name="ride_closing_after_opening",
condition=models.Q(closing_date__isnull=True)
| models.Q(opening_date__isnull=True)
| models.Q(closing_date__gte=models.F("opening_date")),
violation_error_message="Closing date must be after opening date",
),
# Business rule: Height requirements must be logical
models.CheckConstraint(
name="ride_height_requirements_logical",
condition=models.Q(min_height_in__isnull=True)
| models.Q(max_height_in__isnull=True)
| models.Q(min_height_in__lte=models.F("max_height_in")),
violation_error_message="Minimum height cannot exceed maximum height",
),
# Business rule: Height requirements must be reasonable (between 30
# and 90 inches)
models.CheckConstraint(
name="ride_min_height_reasonable",
condition=models.Q(min_height_in__isnull=True)
| (models.Q(min_height_in__gte=30) & models.Q(min_height_in__lte=90)),
violation_error_message=(
"Minimum height must be between 30 and 90 inches"
),
),
models.CheckConstraint(
name="ride_max_height_reasonable",
condition=models.Q(max_height_in__isnull=True)
| (models.Q(max_height_in__gte=30) & models.Q(max_height_in__lte=90)),
violation_error_message=(
"Maximum height must be between 30 and 90 inches"
),
),
# Business rule: Rating must be between 1 and 10
models.CheckConstraint(
name="ride_rating_range",
condition=models.Q(average_rating__isnull=True)
| (models.Q(average_rating__gte=1) & models.Q(average_rating__lte=10)),
violation_error_message="Average rating must be between 1 and 10",
),
# Business rule: Capacity and duration must be positive
models.CheckConstraint(
name="ride_capacity_positive",
condition=models.Q(capacity_per_hour__isnull=True)
| models.Q(capacity_per_hour__gt=0),
violation_error_message="Hourly capacity must be positive",
),
models.CheckConstraint(
name="ride_duration_positive",
condition=models.Q(ride_duration_seconds__isnull=True)
| models.Q(ride_duration_seconds__gt=0),
violation_error_message="Ride duration must be positive",
),
]
def __str__(self) -> str:
return f"{self.name} at {self.park.name}"
def save(self, *args, **kwargs) -> None:
# Handle slug generation and conflicts
if not self.slug:
self.slug = slugify(self.name)
# Check for slug conflicts when park changes or slug is new
original_ride = None
if self.pk:
try:
original_ride = Ride.objects.get(pk=self.pk)
except Ride.DoesNotExist:
pass
# If park changed or this is a new ride, ensure slug uniqueness within the park
park_changed = original_ride and original_ride.park.id != self.park.id
if not self.pk or park_changed:
self._ensure_unique_slug_in_park()
# Handle park area validation when park changes
if park_changed and self.park_area:
# Check if park_area belongs to the new park
if self.park_area.park.id != self.park.id:
# Clear park_area if it doesn't belong to the new park
self.park_area = None
# Sync manufacturer with ride model's manufacturer
if self.ride_model and self.ride_model.manufacturer:
self.manufacturer = self.ride_model.manufacturer
elif self.ride_model and not self.ride_model.manufacturer:
# If ride model has no manufacturer, clear the ride's manufacturer
# to maintain consistency
self.manufacturer = None
# Generate frontend URLs
if self.park:
frontend_domain = getattr(
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
)
self.url = f"{frontend_domain}/parks/{self.park.slug}/rides/{self.slug}/"
self.park_url = f"{frontend_domain}/parks/{self.park.slug}/"
# Populate computed fields
self._populate_computed_fields()
super().save(*args, **kwargs)
def _populate_computed_fields(self) -> None:
"""Populate computed fields for hybrid filtering."""
# Extract opening year from opening_date
if self.opening_date:
self.opening_year = self.opening_date.year
else:
self.opening_year = None
# Build comprehensive search text
search_parts = []
# Basic ride info
if self.name:
search_parts.append(self.name)
if self.description:
search_parts.append(self.description)
# Park info
if self.park:
search_parts.append(self.park.name)
if hasattr(self.park, 'location') and self.park.location:
if self.park.location.city:
search_parts.append(self.park.location.city)
if self.park.location.state:
search_parts.append(self.park.location.state)
if self.park.location.country:
search_parts.append(self.park.location.country)
# Park area
if self.park_area:
search_parts.append(self.park_area.name)
# Category
if self.category:
category_choice = self.get_category_rich_choice()
if category_choice:
search_parts.append(category_choice.label)
# Status
if self.status:
status_choice = self.get_status_rich_choice()
if status_choice:
search_parts.append(status_choice.label)
# Companies
if self.manufacturer:
search_parts.append(self.manufacturer.name)
if self.designer:
search_parts.append(self.designer.name)
# Ride model
if self.ride_model:
search_parts.append(self.ride_model.name)
if self.ride_model.manufacturer:
search_parts.append(self.ride_model.manufacturer.name)
# Roller coaster stats if available
try:
if hasattr(self, 'coaster_stats') and self.coaster_stats:
stats = self.coaster_stats
if stats.track_type:
search_parts.append(stats.track_type)
if stats.track_material:
material_choice = stats.get_track_material_rich_choice()
if material_choice:
search_parts.append(material_choice.label)
if stats.roller_coaster_type:
type_choice = stats.get_roller_coaster_type_rich_choice()
if type_choice:
search_parts.append(type_choice.label)
if stats.propulsion_system:
propulsion_choice = stats.get_propulsion_system_rich_choice()
if propulsion_choice:
search_parts.append(propulsion_choice.label)
if stats.train_style:
search_parts.append(stats.train_style)
except Exception:
# Ignore if coaster_stats doesn't exist or has issues
pass
self.search_text = ' '.join(filter(None, search_parts)).lower()
def _ensure_unique_slug_in_park(self) -> None:
"""Ensure the ride's slug is unique within its park."""
base_slug = slugify(self.name)
self.slug = base_slug
counter = 1
while (
Ride.objects.filter(park=self.park, slug=self.slug)
.exclude(pk=self.pk)
.exists()
):
self.slug = f"{base_slug}-{counter}"
counter += 1
def move_to_park(self, new_park, clear_park_area=True):
"""
Move this ride to a different park with proper handling of related data.
Args:
new_park: The new Park instance to move the ride to
clear_park_area: Whether to clear park_area (default True, since areas are park-specific)
Returns:
dict: Summary of changes made
"""
old_park = self.park
old_url = self.url
old_park_area = self.park_area
# Update park
self.park = new_park
# Handle park area
if clear_park_area:
self.park_area = None
# Save will handle slug conflicts and URL updates
self.save()
# Return summary of changes
changes = {
'old_park': {
'id': old_park.id,
'name': old_park.name,
'slug': old_park.slug
},
'new_park': {
'id': new_park.id,
'name': new_park.name,
'slug': new_park.slug
},
'url_changed': old_url != self.url,
'old_url': old_url,
'new_url': self.url,
'park_area_cleared': clear_park_area and old_park_area is not None,
'old_park_area': {
'id': old_park_area.id,
'name': old_park_area.name
} if old_park_area else None,
'slug_changed': self.slug != slugify(self.name),
'final_slug': self.slug
}
return changes
@classmethod
def get_by_slug(cls, slug: str, park=None) -> tuple["Ride", bool]:
"""Get ride by current or historical slug, optionally within a specific park"""
from django.contrib.contenttypes.models import ContentType
from apps.core.history import HistoricalSlug
# Build base query
base_query = cls.objects
if park:
base_query = base_query.filter(park=park)
try:
ride = base_query.get(slug=slug)
return ride, False
except cls.DoesNotExist:
# Try historical slugs in HistoricalSlug model
content_type = ContentType.objects.get_for_model(cls)
historical_query = HistoricalSlug.objects.filter(
content_type=content_type, slug=slug
).order_by("-created_at")
for historical in historical_query:
try:
ride = base_query.get(pk=historical.object_id)
return ride, True
except cls.DoesNotExist:
continue
# Try pghistory events
event_model = getattr(cls, "event_model", None)
if event_model:
historical_events = event_model.objects.filter(slug=slug).order_by("-pgh_created_at")
for historical_event in historical_events:
try:
ride = base_query.get(pk=historical_event.pgh_obj_id)
return ride, True
except cls.DoesNotExist:
continue
raise cls.DoesNotExist("No ride found with this slug")
@pghistory.track()
class RollerCoasterStats(models.Model):
"""Model for tracking roller coaster specific statistics"""
ride = models.OneToOneField(
Ride, on_delete=models.CASCADE, related_name="coaster_stats"
)
height_ft = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True
)
length_ft = models.DecimalField(
max_digits=7, decimal_places=2, null=True, blank=True
)
speed_mph = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True
)
inversions = models.PositiveIntegerField(default=0)
ride_time_seconds = models.PositiveIntegerField(null=True, blank=True)
track_type = models.CharField(max_length=255, blank=True)
track_material = RichChoiceField(
choice_group="track_materials",
domain="rides",
max_length=20,
default="STEEL",
blank=True,
help_text="Track construction material type"
)
roller_coaster_type = RichChoiceField(
choice_group="coaster_types",
domain="rides",
max_length=20,
default="SITDOWN",
blank=True,
help_text="Roller coaster type classification"
)
max_drop_height_ft = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True
)
propulsion_system = RichChoiceField(
choice_group="propulsion_systems",
domain="rides",
max_length=20,
default="CHAIN",
help_text="Propulsion or lift system type"
)
train_style = models.CharField(max_length=255, blank=True)
trains_count = models.PositiveIntegerField(null=True, blank=True)
cars_per_train = models.PositiveIntegerField(null=True, blank=True)
seats_per_car = models.PositiveIntegerField(null=True, blank=True)
class Meta:
verbose_name = "Roller Coaster Statistics"
verbose_name_plural = "Roller Coaster Statistics"
def __str__(self) -> str:
return f"Stats for {self.ride.name}"

View File

@@ -1,784 +0,0 @@
"""
Smart Ride Loader for Hybrid Filtering Strategy
This service implements intelligent data loading for rides, automatically choosing
between client-side and server-side filtering based on data size and complexity.
Key Features:
- Automatic strategy selection (≤200 records = client-side, >200 = server-side)
- Progressive loading for large datasets
- Intelligent caching with automatic invalidation
- Comprehensive filter metadata generation
- Optimized database queries with strategic prefetching
Architecture:
- Client-side: Load all data once, filter in frontend
- Server-side: Apply filters in database, paginate results
- Hybrid: Combine both approaches based on data characteristics
"""
from typing import Dict, List, Any, Optional
from django.core.cache import cache
from django.db import models
from django.db.models import Q, Min, Max
import logging
logger = logging.getLogger(__name__)
class SmartRideLoader:
"""
Intelligent ride data loader that chooses optimal filtering strategy.
Strategy Selection:
- ≤200 total records: Client-side filtering (load all data)
- >200 total records: Server-side filtering (database filtering + pagination)
Features:
- Progressive loading for large datasets
- 5-minute intelligent caching
- Comprehensive filter metadata
- Optimized queries with prefetch_related
"""
# Configuration constants
INITIAL_LOAD_SIZE = 50
PROGRESSIVE_LOAD_SIZE = 25
MAX_CLIENT_SIDE_RECORDS = 200
CACHE_TIMEOUT = 300 # 5 minutes
def __init__(self):
self.cache_prefix = "rides_hybrid_"
def get_initial_load(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Get initial data load with automatic strategy selection.
Args:
filters: Optional filter parameters
Returns:
Dict containing:
- strategy: 'client_side' or 'server_side'
- data: List of ride records
- total_count: Total number of records
- has_more: Whether more data is available
- filter_metadata: Available filter options
"""
# Get total count for strategy decision
total_count = self._get_total_count(filters)
# Choose strategy based on total count
if total_count <= self.MAX_CLIENT_SIDE_RECORDS:
return self._get_client_side_data(filters, total_count)
else:
return self._get_server_side_data(filters, total_count)
def get_progressive_load(self, offset: int, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Get additional data for progressive loading (server-side strategy only).
Args:
offset: Number of records to skip
filters: Filter parameters
Returns:
Dict containing additional ride records
"""
# Build queryset with filters
queryset = self._build_filtered_queryset(filters)
# Get total count for this filtered set
total_count = queryset.count()
# Get progressive batch
rides = list(queryset[offset:offset + self.PROGRESSIVE_LOAD_SIZE])
return {
'rides': self._serialize_rides(rides),
'total_count': total_count,
'has_more': len(rides) == self.PROGRESSIVE_LOAD_SIZE,
'next_offset': offset + len(rides) if len(rides) == self.PROGRESSIVE_LOAD_SIZE else None
}
def get_filter_metadata(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Get comprehensive filter metadata for dynamic filter generation.
Args:
filters: Optional filters to scope the metadata
Returns:
Dict containing all available filter options and ranges
"""
cache_key = f"{self.cache_prefix}filter_metadata_{hash(str(filters))}"
metadata = cache.get(cache_key)
if metadata is None:
metadata = self._generate_filter_metadata(filters)
cache.set(cache_key, metadata, self.CACHE_TIMEOUT)
return metadata
def invalidate_cache(self) -> None:
"""Invalidate all cached data for rides."""
# Note: In production, you might want to use cache versioning
# or more sophisticated cache invalidation
cache_keys = [
f"{self.cache_prefix}client_side_all",
f"{self.cache_prefix}filter_metadata",
f"{self.cache_prefix}total_count",
]
for key in cache_keys:
cache.delete(key)
def _get_total_count(self, filters: Optional[Dict[str, Any]] = None) -> int:
"""Get total count of rides matching filters."""
cache_key = f"{self.cache_prefix}total_count_{hash(str(filters))}"
count = cache.get(cache_key)
if count is None:
queryset = self._build_filtered_queryset(filters)
count = queryset.count()
cache.set(cache_key, count, self.CACHE_TIMEOUT)
return count
def _get_client_side_data(self, filters: Optional[Dict[str, Any]],
total_count: int) -> Dict[str, Any]:
"""Get all data for client-side filtering."""
cache_key = f"{self.cache_prefix}client_side_all"
cached_data = cache.get(cache_key)
if cached_data is None:
from apps.rides.models import Ride
# Load all rides with optimized query
queryset = Ride.objects.select_related(
'park',
'park__location',
'park_area',
'manufacturer',
'designer',
'ride_model',
'ride_model__manufacturer'
).prefetch_related(
'coaster_stats'
).order_by('name')
rides = list(queryset)
cached_data = self._serialize_rides(rides)
cache.set(cache_key, cached_data, self.CACHE_TIMEOUT)
return {
'strategy': 'client_side',
'rides': cached_data,
'total_count': total_count,
'has_more': False,
'filter_metadata': self.get_filter_metadata(filters)
}
def _get_server_side_data(self, filters: Optional[Dict[str, Any]],
total_count: int) -> Dict[str, Any]:
"""Get initial batch for server-side filtering."""
# Build filtered queryset
queryset = self._build_filtered_queryset(filters)
# Get initial batch
rides = list(queryset[:self.INITIAL_LOAD_SIZE])
return {
'strategy': 'server_side',
'rides': self._serialize_rides(rides),
'total_count': total_count,
'has_more': len(rides) == self.INITIAL_LOAD_SIZE,
'next_offset': len(rides) if len(rides) == self.INITIAL_LOAD_SIZE else None
}
def _build_filtered_queryset(self, filters: Optional[Dict[str, Any]]):
"""Build Django queryset with applied filters."""
from apps.rides.models import Ride
# Start with optimized base queryset
queryset = Ride.objects.select_related(
'park',
'park__location',
'park_area',
'manufacturer',
'designer',
'ride_model',
'ride_model__manufacturer'
).prefetch_related(
'coaster_stats'
)
if not filters:
return queryset.order_by('name')
# Apply filters
q_objects = Q()
# Text search using computed search_text field
if 'search' in filters and filters['search']:
search_term = filters['search'].lower()
q_objects &= Q(search_text__icontains=search_term)
# Park filters
if 'park_slug' in filters and filters['park_slug']:
q_objects &= Q(park__slug=filters['park_slug'])
if 'park_id' in filters and filters['park_id']:
q_objects &= Q(park_id=filters['park_id'])
# Category filters
if 'category' in filters and filters['category']:
q_objects &= Q(category__in=filters['category'])
# Status filters
if 'status' in filters and filters['status']:
q_objects &= Q(status__in=filters['status'])
# Company filters
if 'manufacturer_ids' in filters and filters['manufacturer_ids']:
q_objects &= Q(manufacturer_id__in=filters['manufacturer_ids'])
if 'designer_ids' in filters and filters['designer_ids']:
q_objects &= Q(designer_id__in=filters['designer_ids'])
# Ride model filters
if 'ride_model_ids' in filters and filters['ride_model_ids']:
q_objects &= Q(ride_model_id__in=filters['ride_model_ids'])
# Opening year filters using computed opening_year field
if 'opening_year' in filters and filters['opening_year']:
q_objects &= Q(opening_year=filters['opening_year'])
if 'min_opening_year' in filters and filters['min_opening_year']:
q_objects &= Q(opening_year__gte=filters['min_opening_year'])
if 'max_opening_year' in filters and filters['max_opening_year']:
q_objects &= Q(opening_year__lte=filters['max_opening_year'])
# Rating filters
if 'min_rating' in filters and filters['min_rating']:
q_objects &= Q(average_rating__gte=filters['min_rating'])
if 'max_rating' in filters and filters['max_rating']:
q_objects &= Q(average_rating__lte=filters['max_rating'])
# Height requirement filters
if 'min_height_requirement' in filters and filters['min_height_requirement']:
q_objects &= Q(min_height_in__gte=filters['min_height_requirement'])
if 'max_height_requirement' in filters and filters['max_height_requirement']:
q_objects &= Q(max_height_in__lte=filters['max_height_requirement'])
# Capacity filters
if 'min_capacity' in filters and filters['min_capacity']:
q_objects &= Q(capacity_per_hour__gte=filters['min_capacity'])
if 'max_capacity' in filters and filters['max_capacity']:
q_objects &= Q(capacity_per_hour__lte=filters['max_capacity'])
# Roller coaster specific filters
if 'roller_coaster_type' in filters and filters['roller_coaster_type']:
q_objects &= Q(coaster_stats__roller_coaster_type__in=filters['roller_coaster_type'])
if 'track_material' in filters and filters['track_material']:
q_objects &= Q(coaster_stats__track_material__in=filters['track_material'])
if 'propulsion_system' in filters and filters['propulsion_system']:
q_objects &= Q(coaster_stats__propulsion_system__in=filters['propulsion_system'])
# Roller coaster height filters
if 'min_height_ft' in filters and filters['min_height_ft']:
q_objects &= Q(coaster_stats__height_ft__gte=filters['min_height_ft'])
if 'max_height_ft' in filters and filters['max_height_ft']:
q_objects &= Q(coaster_stats__height_ft__lte=filters['max_height_ft'])
# Roller coaster speed filters
if 'min_speed_mph' in filters and filters['min_speed_mph']:
q_objects &= Q(coaster_stats__speed_mph__gte=filters['min_speed_mph'])
if 'max_speed_mph' in filters and filters['max_speed_mph']:
q_objects &= Q(coaster_stats__speed_mph__lte=filters['max_speed_mph'])
# Inversion filters
if 'min_inversions' in filters and filters['min_inversions']:
q_objects &= Q(coaster_stats__inversions__gte=filters['min_inversions'])
if 'max_inversions' in filters and filters['max_inversions']:
q_objects &= Q(coaster_stats__inversions__lte=filters['max_inversions'])
if 'has_inversions' in filters and filters['has_inversions'] is not None:
if filters['has_inversions']:
q_objects &= Q(coaster_stats__inversions__gt=0)
else:
q_objects &= Q(coaster_stats__inversions=0)
# Apply filters and ordering
queryset = queryset.filter(q_objects)
# Apply ordering
ordering = filters.get('ordering', 'name')
if ordering in ['height_ft', '-height_ft', 'speed_mph', '-speed_mph']:
# For coaster stats ordering, we need to join and order by the stats
ordering_field = ordering.replace('height_ft', 'coaster_stats__height_ft').replace('speed_mph', 'coaster_stats__speed_mph')
queryset = queryset.order_by(ordering_field)
else:
queryset = queryset.order_by(ordering)
return queryset
def _serialize_rides(self, rides: List) -> List[Dict[str, Any]]:
"""Serialize ride objects to dictionaries."""
serialized = []
for ride in rides:
# Basic ride data
ride_data = {
'id': ride.id,
'name': ride.name,
'slug': ride.slug,
'description': ride.description,
'category': ride.category,
'status': ride.status,
'opening_date': ride.opening_date.isoformat() if ride.opening_date else None,
'closing_date': ride.closing_date.isoformat() if ride.closing_date else None,
'opening_year': ride.opening_year,
'min_height_in': ride.min_height_in,
'max_height_in': ride.max_height_in,
'capacity_per_hour': ride.capacity_per_hour,
'ride_duration_seconds': ride.ride_duration_seconds,
'average_rating': float(ride.average_rating) if ride.average_rating else None,
'url': ride.url,
'park_url': ride.park_url,
'created_at': ride.created_at.isoformat(),
'updated_at': ride.updated_at.isoformat(),
}
# Park data
if ride.park:
ride_data['park'] = {
'id': ride.park.id,
'name': ride.park.name,
'slug': ride.park.slug,
}
# Park location data
if hasattr(ride.park, 'location') and ride.park.location:
ride_data['park']['location'] = {
'city': ride.park.location.city,
'state': ride.park.location.state,
'country': ride.park.location.country,
}
# Park area data
if ride.park_area:
ride_data['park_area'] = {
'id': ride.park_area.id,
'name': ride.park_area.name,
'slug': ride.park_area.slug,
}
# Company data
if ride.manufacturer:
ride_data['manufacturer'] = {
'id': ride.manufacturer.id,
'name': ride.manufacturer.name,
'slug': ride.manufacturer.slug,
}
if ride.designer:
ride_data['designer'] = {
'id': ride.designer.id,
'name': ride.designer.name,
'slug': ride.designer.slug,
}
# Ride model data
if ride.ride_model:
ride_data['ride_model'] = {
'id': ride.ride_model.id,
'name': ride.ride_model.name,
'slug': ride.ride_model.slug,
'category': ride.ride_model.category,
}
if ride.ride_model.manufacturer:
ride_data['ride_model']['manufacturer'] = {
'id': ride.ride_model.manufacturer.id,
'name': ride.ride_model.manufacturer.name,
'slug': ride.ride_model.manufacturer.slug,
}
# Roller coaster stats
if hasattr(ride, 'coaster_stats') and ride.coaster_stats:
stats = ride.coaster_stats
ride_data['coaster_stats'] = {
'height_ft': float(stats.height_ft) if stats.height_ft else None,
'length_ft': float(stats.length_ft) if stats.length_ft else None,
'speed_mph': float(stats.speed_mph) if stats.speed_mph else None,
'inversions': stats.inversions,
'ride_time_seconds': stats.ride_time_seconds,
'track_type': stats.track_type,
'track_material': stats.track_material,
'roller_coaster_type': stats.roller_coaster_type,
'max_drop_height_ft': float(stats.max_drop_height_ft) if stats.max_drop_height_ft else None,
'propulsion_system': stats.propulsion_system,
'train_style': stats.train_style,
'trains_count': stats.trains_count,
'cars_per_train': stats.cars_per_train,
'seats_per_car': stats.seats_per_car,
}
serialized.append(ride_data)
return serialized
def _generate_filter_metadata(self, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Generate comprehensive filter metadata."""
from apps.rides.models import Ride, RideModel
from apps.rides.models.company import Company
from apps.rides.models.rides import RollerCoasterStats
# Get unique values from database with counts
parks_data = list(Ride.objects.exclude(
park__isnull=True
).select_related('park').values(
'park__id', 'park__name', 'park__slug'
).annotate(count=models.Count('id')).distinct().order_by('park__name'))
park_areas_data = list(Ride.objects.exclude(
park_area__isnull=True
).select_related('park_area').values(
'park_area__id', 'park_area__name', 'park_area__slug'
).annotate(count=models.Count('id')).distinct().order_by('park_area__name'))
manufacturers_data = list(Company.objects.filter(
roles__contains=['MANUFACTURER']
).values('id', 'name', 'slug').annotate(
count=models.Count('manufactured_rides')
).order_by('name'))
designers_data = list(Company.objects.filter(
roles__contains=['DESIGNER']
).values('id', 'name', 'slug').annotate(
count=models.Count('designed_rides')
).order_by('name'))
ride_models_data = list(RideModel.objects.select_related(
'manufacturer'
).values(
'id', 'name', 'slug', 'manufacturer__name', 'manufacturer__slug', 'category'
).annotate(count=models.Count('rides')).order_by('manufacturer__name', 'name'))
# Get categories and statuses with counts
categories_data = list(Ride.objects.values('category').annotate(
count=models.Count('id')
).order_by('category'))
statuses_data = list(Ride.objects.values('status').annotate(
count=models.Count('id')
).order_by('status'))
# Get roller coaster specific data with counts
rc_types_data = list(RollerCoasterStats.objects.values('roller_coaster_type').annotate(
count=models.Count('ride')
).exclude(roller_coaster_type__isnull=True).order_by('roller_coaster_type'))
track_materials_data = list(RollerCoasterStats.objects.values('track_material').annotate(
count=models.Count('ride')
).exclude(track_material__isnull=True).order_by('track_material'))
propulsion_systems_data = list(RollerCoasterStats.objects.values('propulsion_system').annotate(
count=models.Count('ride')
).exclude(propulsion_system__isnull=True).order_by('propulsion_system'))
# Convert to frontend-expected format with value/label/count
categories = [
{
'value': item['category'],
'label': self._get_category_label(item['category']),
'count': item['count']
}
for item in categories_data
]
statuses = [
{
'value': item['status'],
'label': self._get_status_label(item['status']),
'count': item['count']
}
for item in statuses_data
]
roller_coaster_types = [
{
'value': item['roller_coaster_type'],
'label': self._get_rc_type_label(item['roller_coaster_type']),
'count': item['count']
}
for item in rc_types_data
]
track_materials = [
{
'value': item['track_material'],
'label': self._get_track_material_label(item['track_material']),
'count': item['count']
}
for item in track_materials_data
]
propulsion_systems = [
{
'value': item['propulsion_system'],
'label': self._get_propulsion_system_label(item['propulsion_system']),
'count': item['count']
}
for item in propulsion_systems_data
]
# Convert other data to expected format
parks = [
{
'value': str(item['park__id']),
'label': item['park__name'],
'count': item['count']
}
for item in parks_data
]
park_areas = [
{
'value': str(item['park_area__id']),
'label': item['park_area__name'],
'count': item['count']
}
for item in park_areas_data
]
manufacturers = [
{
'value': str(item['id']),
'label': item['name'],
'count': item['count']
}
for item in manufacturers_data
]
designers = [
{
'value': str(item['id']),
'label': item['name'],
'count': item['count']
}
for item in designers_data
]
ride_models = [
{
'value': str(item['id']),
'label': f"{item['manufacturer__name']} {item['name']}",
'count': item['count']
}
for item in ride_models_data
]
# Calculate ranges from actual data
ride_stats = Ride.objects.aggregate(
min_rating=Min('average_rating'),
max_rating=Max('average_rating'),
min_height_req=Min('min_height_in'),
max_height_req=Max('max_height_in'),
min_capacity=Min('capacity_per_hour'),
max_capacity=Max('capacity_per_hour'),
min_duration=Min('ride_duration_seconds'),
max_duration=Max('ride_duration_seconds'),
min_year=Min('opening_year'),
max_year=Max('opening_year'),
)
# Calculate roller coaster specific ranges
coaster_stats = RollerCoasterStats.objects.aggregate(
min_height_ft=Min('height_ft'),
max_height_ft=Max('height_ft'),
min_length_ft=Min('length_ft'),
max_length_ft=Max('length_ft'),
min_speed_mph=Min('speed_mph'),
max_speed_mph=Max('speed_mph'),
min_inversions=Min('inversions'),
max_inversions=Max('inversions'),
min_ride_time=Min('ride_time_seconds'),
max_ride_time=Max('ride_time_seconds'),
min_drop_height=Min('max_drop_height_ft'),
max_drop_height=Max('max_drop_height_ft'),
min_trains=Min('trains_count'),
max_trains=Max('trains_count'),
min_cars=Min('cars_per_train'),
max_cars=Max('cars_per_train'),
min_seats=Min('seats_per_car'),
max_seats=Max('seats_per_car'),
)
return {
'categorical': {
'categories': categories,
'statuses': statuses,
'roller_coaster_types': roller_coaster_types,
'track_materials': track_materials,
'propulsion_systems': propulsion_systems,
'parks': parks,
'park_areas': park_areas,
'manufacturers': manufacturers,
'designers': designers,
'ride_models': ride_models,
},
'ranges': {
'rating': {
'min': float(ride_stats['min_rating'] or 1),
'max': float(ride_stats['max_rating'] or 10),
'step': 0.1,
'unit': 'stars'
},
'height_requirement': {
'min': ride_stats['min_height_req'] or 30,
'max': ride_stats['max_height_req'] or 90,
'step': 1,
'unit': 'inches'
},
'capacity': {
'min': ride_stats['min_capacity'] or 0,
'max': ride_stats['max_capacity'] or 5000,
'step': 50,
'unit': 'riders/hour'
},
'ride_duration': {
'min': ride_stats['min_duration'] or 0,
'max': ride_stats['max_duration'] or 600,
'step': 10,
'unit': 'seconds'
},
'opening_year': {
'min': ride_stats['min_year'] or 1800,
'max': ride_stats['max_year'] or 2030,
'step': 1,
'unit': 'year'
},
'height_ft': {
'min': float(coaster_stats['min_height_ft'] or 0),
'max': float(coaster_stats['max_height_ft'] or 500),
'step': 5,
'unit': 'feet'
},
'length_ft': {
'min': float(coaster_stats['min_length_ft'] or 0),
'max': float(coaster_stats['max_length_ft'] or 10000),
'step': 100,
'unit': 'feet'
},
'speed_mph': {
'min': float(coaster_stats['min_speed_mph'] or 0),
'max': float(coaster_stats['max_speed_mph'] or 150),
'step': 5,
'unit': 'mph'
},
'inversions': {
'min': coaster_stats['min_inversions'] or 0,
'max': coaster_stats['max_inversions'] or 20,
'step': 1,
'unit': 'inversions'
},
},
'total_count': Ride.objects.count(),
}
def _get_category_label(self, category: str) -> str:
"""Convert category code to human-readable label."""
category_labels = {
'RC': 'Roller Coaster',
'DR': 'Dark Ride',
'FR': 'Flat Ride',
'WR': 'Water Ride',
'TR': 'Transport Ride',
'OT': 'Other',
}
if category in category_labels:
return category_labels[category]
else:
raise ValueError(f"Unknown ride category: {category}")
def _get_status_label(self, status: str) -> str:
"""Convert status code to human-readable label."""
status_labels = {
'OPERATING': 'Operating',
'CLOSED_TEMP': 'Temporarily Closed',
'SBNO': 'Standing But Not Operating',
'CLOSING': 'Closing Soon',
'CLOSED_PERM': 'Permanently Closed',
'UNDER_CONSTRUCTION': 'Under Construction',
'DEMOLISHED': 'Demolished',
'RELOCATED': 'Relocated',
}
if status in status_labels:
return status_labels[status]
else:
raise ValueError(f"Unknown ride status: {status}")
def _get_rc_type_label(self, rc_type: str) -> str:
"""Convert roller coaster type to human-readable label."""
rc_type_labels = {
'SITDOWN': 'Sit Down',
'INVERTED': 'Inverted',
'SUSPENDED': 'Suspended',
'FLOORLESS': 'Floorless',
'FLYING': 'Flying',
'WING': 'Wing',
'DIVE': 'Dive',
'SPINNING': 'Spinning',
'WILD_MOUSE': 'Wild Mouse',
'BOBSLED': 'Bobsled',
'PIPELINE': 'Pipeline',
'FOURTH_DIMENSION': '4th Dimension',
'FAMILY': 'Family',
}
if rc_type in rc_type_labels:
return rc_type_labels[rc_type]
else:
raise ValueError(f"Unknown roller coaster type: {rc_type}")
def _get_track_material_label(self, material: str) -> str:
"""Convert track material to human-readable label."""
material_labels = {
'STEEL': 'Steel',
'WOOD': 'Wood',
'HYBRID': 'Hybrid (Steel/Wood)',
}
if material in material_labels:
return material_labels[material]
else:
raise ValueError(f"Unknown track material: {material}")
def _get_propulsion_system_label(self, propulsion_system: str) -> str:
"""Convert propulsion system to human-readable label."""
propulsion_labels = {
'CHAIN': 'Chain Lift',
'LSM': 'Linear Synchronous Motor',
'LIM': 'Linear Induction Motor',
'HYDRAULIC': 'Hydraulic Launch',
'PNEUMATIC': 'Pneumatic Launch',
'CABLE': 'Cable Lift',
'FLYWHEEL': 'Flywheel Launch',
'GRAVITY': 'Gravity',
'NONE': 'No Propulsion System',
}
if propulsion_system in propulsion_labels:
return propulsion_labels[propulsion_system]
else:
raise ValueError(f"Unknown propulsion system: {propulsion_system}")

31
backend/.env.example Normal file
View File

@@ -0,0 +1,31 @@
# Django Configuration
SECRET_KEY=your-secret-key-here
DEBUG=True
DJANGO_SETTINGS_MODULE=config.django.local
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/thrillwiki
# Redis
REDIS_URL=redis://localhost:6379
# Email Configuration (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
# 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

2
backend/.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# SCM syntax highlighting & preventing 3-way merges
pixi.lock merge=binary linguist-language=YAML linguist-generated=true

3
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# pixi environments
.pixi/*
!.pixi/config.toml

229
backend/README.md Normal file
View File

@@ -0,0 +1,229 @@
# ThrillWiki Backend
Django REST API backend for the ThrillWiki monorepo.
## 🏗️ Architecture
This backend follows Django best practices with a modular app structure:
```
backend/
├── apps/ # Django applications
│ ├── accounts/ # User management
│ ├── parks/ # Theme park data
│ ├── rides/ # Ride information
│ ├── moderation/ # Content moderation
│ ├── location/ # Geographic data
│ ├── media/ # File management
│ ├── email_service/ # Email functionality
│ └── core/ # Core utilities
├── config/ # Django configuration
│ ├── django/ # Settings files
│ └── settings/ # Modular settings
├── templates/ # Django templates
├── static/ # Static files
└── tests/ # Test files
```
## 🛠️ Technology Stack
- **Django 5.0+** - Web framework
- **Django REST Framework** - API framework
- **PostgreSQL** - Primary database
- **Redis** - Caching and sessions
- **UV** - Python package management
- **Celery** - Background task processing
## 🚀 Quick Start
### Prerequisites
- Python 3.11+
- [uv](https://docs.astral.sh/uv/) package manager
- PostgreSQL 14+
- Redis 6+
### Setup
1. **Install dependencies**
```bash
cd backend
uv sync
```
2. **Environment configuration**
```bash
cp .env.example .env
# Edit .env with your settings
```
3. **Database setup**
```bash
uv run manage.py migrate
uv run manage.py createsuperuser
```
4. **Start development server**
```bash
uv run manage.py runserver
```
## 🔧 Configuration
### Environment Variables
Required environment variables:
```bash
# Database
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
# Django
SECRET_KEY=your-secret-key
DEBUG=True
DJANGO_SETTINGS_MODULE=config.django.local
# Redis
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
- **accounts** - User authentication and profile management
- **parks** - Theme park models and operations
- **rides** - Ride information and relationships
- **core** - Shared utilities and base classes
### Support Apps
- **moderation** - Content moderation workflows
- **location** - Geographic data and services
- **media** - File upload and management
- **email_service** - Email sending and templates
## 🔌 API Endpoints
Base URL: `http://localhost:8000/api/`
### Authentication
- `POST /auth/login/` - User login
- `POST /auth/logout/` - User logout
- `POST /auth/register/` - User registration
### Parks
- `GET /parks/` - List parks
- `GET /parks/{id}/` - Park details
- `POST /parks/` - Create park (admin)
### Rides
- `GET /rides/` - List rides
- `GET /rides/{id}/` - Ride details
- `GET /parks/{park_id}/rides/` - Rides by park
## 🧪 Testing
```bash
# Run all tests
uv run manage.py test
# Run specific app tests
uv run manage.py test apps.parks
# Run with coverage
uv run coverage run manage.py test
uv run coverage report
```
## 🔧 Management Commands
Custom management commands:
```bash
# Import park data
uv run manage.py import_parks data/parks.json
# Generate test data
uv run manage.py generate_test_data
# Clean up expired sessions
uv run manage.py clearsessions
```
## 📊 Database
### Entity Relationships
- **Parks** have Operators (required) and PropertyOwners (optional)
- **Rides** belong to Parks and may have Manufacturers/Designers
- **Users** can create submissions and moderate content
### Migrations
```bash
# Create migrations
uv run manage.py makemigrations
# Apply migrations
uv run manage.py migrate
# Show migration status
uv run manage.py showmigrations
```
## 🔐 Security
- CORS configured for frontend integration
- CSRF protection enabled
- JWT token authentication
- Rate limiting on API endpoints
- Input validation and sanitization
## 📈 Performance
- Database query optimization
- Redis caching for frequent queries
- Background task processing with Celery
- Database connection pooling
## 🚀 Deployment
See the [Deployment Guide](../shared/docs/deployment/) for production setup.
## 🐛 Debugging
### Development Tools
- Django Debug Toolbar
- Django Extensions
- Silk profiler for performance analysis
### Logging
Logs are written to:
- Console (development)
- Files in `logs/` directory (production)
- External logging service (production)
## 🤝 Contributing
1. Follow Django coding standards
2. Write tests for new features
3. Update documentation
4. Run linting: `uv run flake8 .`
5. Format code: `uv run black .`

View File

@@ -0,0 +1,64 @@
from django.conf import settings
from allauth.account.adapter import DefaultAccountAdapter
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from django.contrib.auth import get_user_model
from django.contrib.sites.shortcuts import get_current_site
User = get_user_model()
class CustomAccountAdapter(DefaultAccountAdapter):
def is_open_for_signup(self, request):
"""
Whether to allow sign ups.
"""
return True
def get_email_confirmation_url(self, request, emailconfirmation):
"""
Constructs the email confirmation (activation) url.
"""
get_current_site(request)
return f"{settings.LOGIN_REDIRECT_URL}verify-email?key={emailconfirmation.key}"
def send_confirmation_mail(self, request, emailconfirmation, signup):
"""
Sends the confirmation email.
"""
current_site = get_current_site(request)
activate_url = self.get_email_confirmation_url(request, emailconfirmation)
ctx = {
"user": emailconfirmation.email_address.user,
"activate_url": activate_url,
"current_site": current_site,
"key": emailconfirmation.key,
}
if signup:
email_template = "account/email/email_confirmation_signup"
else:
email_template = "account/email/email_confirmation"
self.send_mail(email_template, emailconfirmation.email_address.email, ctx)
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
def is_open_for_signup(self, request, sociallogin):
"""
Whether to allow social account sign ups.
"""
return True
def populate_user(self, request, sociallogin, data):
"""
Hook that can be used to further populate the user instance.
"""
user = super().populate_user(request, sociallogin, data)
if sociallogin.account.provider == "discord":
user.discord_id = sociallogin.account.uid
return user
def save_user(self, request, sociallogin, form=None):
"""
Save the newly signed up social login.
"""
user = super().save_user(request, sociallogin, form)
return user

View File

@@ -1,10 +1,7 @@
from typing import Any
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
from django.contrib.auth.admin import UserAdmin
from django.utils.html import format_html
from django.contrib.auth.models import Group
from django.http import HttpRequest
from django.db.models import QuerySet
from .models import (
User,
UserProfile,
@@ -15,7 +12,7 @@ from .models import (
)
class UserProfileInline(admin.StackedInline[UserProfile, admin.options.AdminSite]):
class UserProfileInline(admin.StackedInline):
model = UserProfile
can_delete = False
verbose_name_plural = "Profile"
@@ -42,7 +39,7 @@ class UserProfileInline(admin.StackedInline[UserProfile, admin.options.AdminSite
)
class TopListItemInline(admin.TabularInline[TopListItem]):
class TopListItemInline(admin.TabularInline):
model = TopListItem
extra = 1
fields = ("content_type", "object_id", "rank", "notes")
@@ -50,7 +47,7 @@ class TopListItemInline(admin.TabularInline[TopListItem]):
@admin.register(User)
class CustomUserAdmin(DjangoUserAdmin[User]):
class CustomUserAdmin(UserAdmin):
list_display = (
"username",
"email",
@@ -77,7 +74,7 @@ class CustomUserAdmin(DjangoUserAdmin[User]):
"ban_users",
"unban_users",
]
inlines: list[type[admin.StackedInline[UserProfile]]] = [UserProfileInline]
inlines = [UserProfileInline]
fieldsets = (
(None, {"fields": ("username", "password")}),
@@ -129,82 +126,75 @@ class CustomUserAdmin(DjangoUserAdmin[User]):
)
@admin.display(description="Avatar")
def get_avatar(self, obj: User) -> str:
profile = getattr(obj, "profile", None)
if profile and getattr(profile, "avatar", None):
def get_avatar(self, obj):
if obj.profile.avatar:
return format_html(
'<img src="{0}" width="30" height="30" style="border-radius:50%;" />',
getattr(profile.avatar, "url", ""), # type: ignore
'<img src="{}" width="30" height="30" style="border-radius:50%;" />',
obj.profile.avatar.url,
)
return format_html(
'<div style="width:30px; height:30px; border-radius:50%; '
"background-color:#007bff; color:white; display:flex; "
'align-items:center; justify-content:center;">{0}</div>',
getattr(obj, "username", "?")[0].upper(), # type: ignore
'align-items:center; justify-content:center;">{}</div>',
obj.username[0].upper(),
)
@admin.display(description="Status")
def get_status(self, obj: User) -> str:
if getattr(obj, "is_banned", False):
return format_html('<span style="color: red;">{}</span>', "Banned")
if not getattr(obj, "is_active", True):
return format_html('<span style="color: orange;">{}</span>', "Inactive")
if getattr(obj, "is_superuser", False):
return format_html('<span style="color: purple;">{}</span>', "Superuser")
if getattr(obj, "is_staff", False):
return format_html('<span style="color: blue;">{}</span>', "Staff")
return format_html('<span style="color: green;">{}</span>', "Active")
def get_status(self, obj):
if obj.is_banned:
return format_html('<span style="color: red;">Banned</span>')
if not obj.is_active:
return format_html('<span style="color: orange;">Inactive</span>')
if obj.is_superuser:
return format_html('<span style="color: purple;">Superuser</span>')
if obj.is_staff:
return format_html('<span style="color: blue;">Staff</span>')
return format_html('<span style="color: green;">Active</span>')
@admin.display(description="Ride Credits")
def get_credits(self, obj: User) -> str:
def get_credits(self, obj):
try:
profile = getattr(obj, "profile", None)
if not profile:
return "-"
profile = obj.profile
return format_html(
"RC: {0}<br>DR: {1}<br>FR: {2}<br>WR: {3}",
getattr(profile, "coaster_credits", 0),
getattr(profile, "dark_ride_credits", 0),
getattr(profile, "flat_ride_credits", 0),
getattr(profile, "water_ride_credits", 0),
"RC: {}<br>DR: {}<br>FR: {}<br>WR: {}",
profile.coaster_credits,
profile.dark_ride_credits,
profile.flat_ride_credits,
profile.water_ride_credits,
)
except UserProfile.DoesNotExist:
return "-"
@admin.action(description="Activate selected users")
def activate_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
def activate_users(self, request, queryset):
queryset.update(is_active=True)
@admin.action(description="Deactivate selected users")
def deactivate_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
def deactivate_users(self, request, queryset):
queryset.update(is_active=False)
@admin.action(description="Ban selected users")
def ban_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
def ban_users(self, request, queryset):
from django.utils import timezone
queryset.update(is_banned=True, ban_date=timezone.now())
@admin.action(description="Unban selected users")
def unban_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
def unban_users(self, request, queryset):
queryset.update(is_banned=False, ban_date=None, ban_reason="")
def save_model(
self,
request: HttpRequest,
obj: User,
form: Any,
change: bool
) -> None:
def save_model(self, request, obj, form, change):
creating = not obj.pk
super().save_model(request, obj, form, change)
if creating and getattr(obj, "role", "USER") != "USER":
group = Group.objects.filter(name=getattr(obj, "role", None)).first()
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()
if group:
obj.groups.add(group) # type: ignore[attr-defined]
obj.groups.add(group)
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin[UserProfile]):
class UserProfileAdmin(admin.ModelAdmin):
list_display = (
"user",
"display_name",
@@ -245,7 +235,7 @@ class UserProfileAdmin(admin.ModelAdmin[UserProfile]):
@admin.register(EmailVerification)
class EmailVerificationAdmin(admin.ModelAdmin[EmailVerification]):
class EmailVerificationAdmin(admin.ModelAdmin):
list_display = ("user", "created_at", "last_sent", "is_expired")
list_filter = ("created_at", "last_sent")
search_fields = ("user__username", "user__email", "token")
@@ -257,21 +247,21 @@ class EmailVerificationAdmin(admin.ModelAdmin[EmailVerification]):
)
@admin.display(description="Status")
def is_expired(self, obj: EmailVerification) -> str:
def is_expired(self, obj):
from django.utils import timezone
from datetime import timedelta
if timezone.now() - getattr(obj, "last_sent", timezone.now()) > timedelta(days=1):
return format_html('<span style="color: red;">{}</span>', "Expired")
return format_html('<span style="color: green;">{}</span>', "Valid")
if timezone.now() - obj.last_sent > timedelta(days=1):
return format_html('<span style="color: red;">Expired</span>')
return format_html('<span style="color: green;">Valid</span>')
@admin.register(TopList)
class TopListAdmin(admin.ModelAdmin[TopList]):
class TopListAdmin(admin.ModelAdmin):
list_display = ("title", "user", "category", "created_at", "updated_at")
list_filter = ("category", "created_at", "updated_at")
search_fields = ("title", "user__username", "description")
inlines: list[type[admin.TabularInline[TopListItem]]] = [TopListItemInline]
inlines = [TopListItemInline]
fieldsets = (
(
@@ -287,7 +277,7 @@ class TopListAdmin(admin.ModelAdmin[TopList]):
@admin.register(TopListItem)
class TopListItemAdmin(admin.ModelAdmin[TopListItem]):
class TopListItemAdmin(admin.ModelAdmin):
list_display = ("top_list", "content_type", "object_id", "rank")
list_filter = ("top_list__category", "rank")
search_fields = ("top_list__title", "notes")
@@ -300,7 +290,7 @@ class TopListItemAdmin(admin.ModelAdmin[TopListItem]):
@admin.register(PasswordReset)
class PasswordResetAdmin(admin.ModelAdmin[PasswordReset]):
class PasswordResetAdmin(admin.ModelAdmin):
"""Admin interface for password reset tokens"""
list_display = (
@@ -351,19 +341,20 @@ class PasswordResetAdmin(admin.ModelAdmin[PasswordReset]):
)
@admin.display(description="Status", boolean=True)
def is_expired(self, obj: PasswordReset) -> str:
def is_expired(self, obj):
"""Display expiration status with color coding"""
from django.utils import timezone
if getattr(obj, "used", False):
return format_html('<span style="color: blue;">{}</span>', "Used")
elif timezone.now() > getattr(obj, "expires_at", timezone.now()):
return format_html('<span style="color: red;">{}</span>', "Expired")
return format_html('<span style="color: green;">{}</span>', "Valid")
if obj.used:
return format_html('<span style="color: blue;">Used</span>')
elif timezone.now() > obj.expires_at:
return format_html('<span style="color: red;">Expired</span>')
return format_html('<span style="color: green;">Valid</span>')
def has_add_permission(self, request: HttpRequest) -> bool:
def has_add_permission(self, request):
"""Disable manual creation of password reset tokens"""
return False
def has_change_permission(self, request: HttpRequest, obj: Any = None) -> bool:
def has_change_permission(self, request, obj=None):
"""Allow viewing but restrict editing of password reset tokens"""
return getattr(request.user, "is_superuser", False)

Some files were not shown because too many files have changed in this diff Show More