mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-21 13:51:08 -05:00
Compare commits
2 Commits
dependabot
...
vuejs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dd5d88906 | ||
|
|
c4702559fb |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python manage.py check:*)",
|
||||
"Bash(uv run:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(python:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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!!!
|
||||
54
.github/workflows/claude-code-review.yml
vendored
54
.github/workflows/claude-code-review.yml
vendored
@@ -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:*)"'
|
||||
|
||||
50
.github/workflows/claude.yml
vendored
50
.github/workflows/claude.yml
vendored
@@ -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:*)'
|
||||
|
||||
2
.github/workflows/django.yml
vendored
2
.github/workflows/django.yml
vendored
@@ -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'
|
||||
|
||||
2
.github/workflows/review.yml
vendored
2
.github/workflows/review.yml
vendored
@@ -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
9
.gitignore
vendored
@@ -116,11 +116,4 @@ temp/
|
||||
/backups/
|
||||
.django_tailwind_cli/
|
||||
backend/.env
|
||||
frontend/.env
|
||||
|
||||
# Extracted packages
|
||||
django-forwardemail/
|
||||
frontend/
|
||||
frontend
|
||||
.snapshots
|
||||
uv.lock
|
||||
frontend/.env
|
||||
73
.replit
73
.replit
@@ -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"]
|
||||
@@ -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
277
CI_README.md
Normal 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
443
README.md
@@ -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
326
TAILWIND_V4_MIGRATION.md
Normal 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 ✅
|
||||
80
TAILWIND_V4_QUICK_REFERENCE.md
Normal file
80
TAILWIND_V4_QUICK_REFERENCE.md
Normal 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/)
|
||||
@@ -1,2 +0,0 @@
|
||||
# Import choices to trigger registration
|
||||
from .choices import *
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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']
|
||||
@@ -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
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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']
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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]'
|
||||
@@ -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
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
"""
|
||||
Patches for third-party packages.
|
||||
"""
|
||||
|
||||
# No patches currently applied
|
||||
@@ -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
|
||||
@@ -1,5 +0,0 @@
|
||||
"""
|
||||
Core tasks package for ThrillWiki.
|
||||
|
||||
This package contains all Celery tasks for the core application.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -1,2 +0,0 @@
|
||||
# Import choices to trigger auto-registration with the global registry
|
||||
from . import choices # noqa: F401
|
||||
@@ -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")
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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', [])
|
||||
})
|
||||
@@ -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']
|
||||
@@ -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
@@ -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}"
|
||||
@@ -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
31
backend/.env.example
Normal 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
2
backend/.gitattributes
vendored
Normal 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
3
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# pixi environments
|
||||
.pixi/*
|
||||
!.pixi/config.toml
|
||||
229
backend/README.md
Normal file
229
backend/README.md
Normal 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 .`
|
||||
64
backend/apps/accounts/adapters.py
Normal file
64
backend/apps/accounts/adapters.py
Normal 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
|
||||
@@ -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
Reference in New Issue
Block a user