Compare commits

..

12 Commits

Author SHA1 Message Date
pacnpal
ae31e889d7 Add standardized HTMX conventions, interaction patterns, and migration guide for ThrillWiki UX 2025-12-22 16:56:27 -05:00
pacnpal
2e35f8c5d9 feat: Refactor rides app with unique constraints, mixins, and enhanced documentation
- Added migration to convert unique_together constraints to UniqueConstraint for RideModel.
- Introduced RideFormMixin for handling entity suggestions in ride forms.
- Created comprehensive code standards documentation outlining formatting, docstring requirements, complexity guidelines, and testing requirements.
- Established error handling guidelines with a structured exception hierarchy and best practices for API and view error handling.
- Documented view pattern guidelines, emphasizing the use of CBVs, FBVs, and ViewSets with examples.
- Implemented a benchmarking script for query performance analysis and optimization.
- Developed security documentation detailing measures, configurations, and a security checklist.
- Compiled a database optimization guide covering indexing strategies, query optimization patterns, and computed fields.
2025-12-22 11:17:31 -05:00
pacnpal
45d97b6e68 Add test utilities and state machine diagrams for FSM models
- Introduced reusable test utilities in `backend/tests/utils` for FSM transitions, HTMX interactions, and common scenarios.
- Added factory functions for creating test submissions, parks, rides, and photo submissions.
- Implemented assertion helpers for verifying state changes, toast notifications, and transition logs.
- Created comprehensive state machine diagrams for all FSM-enabled models in `docs/STATE_DIAGRAMS.md`, detailing states, transitions, and guard conditions.
2025-12-22 08:55:39 -05:00
pacnpal
b508434574 Add state machine diagrams and code examples for ThrillWiki
- Created a comprehensive documentation file for state machine diagrams, detailing various states and transitions for models such as EditSubmission, ModerationReport, and Park Status.
- Included transition matrices for each state machine to clarify role requirements and guards.
- Developed a new document providing code examples for implementing state machines, including adding new state machines to models, defining custom guards, implementing callbacks, and testing state machines.
- Added examples for document approval workflows, custom guards, email notifications, and cache invalidation callbacks.
- Implemented a test suite for document workflows, covering various scenarios including approval, rejection, and transition logging.
2025-12-21 20:21:54 -05:00
pacnpal
8f6acbdc23 feat(notifications): enhance submission approval and rejection notifications with dynamic titles and messages 2025-12-21 19:22:15 -05:00
pacnpal
b860e332cb feat(state-machine): add comprehensive callback system for transitions
Extend state machine module with callback infrastructure including:
- Pre/post/error transition callbacks with registry
- Signal-based transition notifications
- Callback configuration and monitoring support
- Helper functions for callback registration
- Improved park ride count updates with FSM integration
2025-12-21 19:20:49 -05:00
pacnpal
7ba0004c93 chore: fix pghistory migration deps and improve htmx utilities
- Update pghistory dependency from 0007 to 0006 in account migrations
- Add docstrings and remove unused imports in htmx_forms.py
- Add DJANGO_SETTINGS_MODULE bash commands to Claude settings
- Add state transition definitions for ride statuses
2025-12-21 17:33:24 -05:00
pacnpal
b9063ff4f8 feat: Add detailed park and ride pages with HTMX integration
- Implemented park detail page with dynamic content loading for rides and weather.
- Created park list page with filters and search functionality.
- Developed ride detail page showcasing ride stats, reviews, and similar rides.
- Added ride list page with filtering options and dynamic loading.
- Introduced search results page with tabs for parks, rides, and users.
- Added HTMX tests for global search functionality.
2025-12-19 19:53:20 -05:00
pacnpal
bf04e4d854 fix: Update import paths to use 'apps' prefix for models and services 2025-09-28 10:50:57 -04:00
pacnpal
1b246eeaa4 Add comprehensive test scripts for various models and services
- Implement tests for RideLocation and CompanyHeadquarters models to verify functionality and data integrity.
- Create a manual trigger test script for trending content calculation endpoint, including authentication and unauthorized access tests.
- Develop a manufacturer sync test to ensure ride manufacturers are correctly associated with ride models.
- Add tests for ParkLocation model, including coordinate setting and distance calculations between parks.
- Implement a RoadTripService test suite covering geocoding, route calculation, park discovery, and error handling.
- Create a unified map service test script to validate map functionality, API endpoints, and performance metrics.
2025-09-27 22:26:40 -04:00
pacnpal
fdbbca2add Refactor code structure for improved readability and maintainability 2025-09-27 19:35:00 -04:00
pacnpal
bf365693f8 fix: Update .gitignore to include .snapshots directory 2025-09-27 12:57:37 -04:00
1097 changed files with 239633 additions and 33367 deletions

View File

@@ -4,9 +4,14 @@
"Bash(python manage.py check:*)",
"Bash(uv run:*)",
"Bash(find:*)",
"Bash(python:*)"
"Bash(python:*)",
"Bash(DJANGO_SETTINGS_MODULE=config.django.local python:*)",
"Bash(DJANGO_SETTINGS_MODULE=config.django.local uv run python:*)",
"Bash(ls:*)",
"Bash(grep:*)",
"Bash(mkdir:*)"
],
"deny": [],
"ask": []
}
}
}

View File

@@ -1,98 +1,91 @@
---
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"]
---
## Brief overview
Critical thinking rules for frontend design decisions. No excuses for poor design choices that ignore user vision.
# ThrillWiki Core Development Rules
## Rule compliance and design decisions
- Read ALL .clinerules files before making any code changes
- Never assume exceptions to rules marked as "MANDATORY"
- Take full responsibility for rule violations without excuses
- Ask "What is the most optimal approach?" before ANY design decision
- Justify every choice against user requirements - not your damn preferences
- Stop making lazy design decisions without evaluation
- Document your reasoning or get destroyed later
## 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.
## User vision, feedback, and assumptions
- Figure out what the user actually wants, not your assumptions
- Ask questions when unclear - stop guessing like an idiot
- Deliver their vision, not your garbage
- User dissatisfaction means you screwed up understanding their vision
- Stop defending your bad choices and listen
- Fix the actual problem, not band-aid symptoms
- Scrap everything and restart if needed
- NEVER assume user preferences without confirmation
- Stop guessing at requirements like a moron
- Your instincts are wrong - question everything
- Get explicit approval or fail
## Implementation and backend integration
- Think before you code, don't just hack away
- Evaluate trade-offs or make terrible decisions
- Question if your solution actually solves their damn problem
- NEVER change color schemes without explicit user approval
- ALWAYS use responsive design principles
- ALWAYS follow best theme choice guidelines so users may choose light or dark mode
- NEVER use quick fixes for complex problems
- Support user goals, not your aesthetic ego
- Follow established patterns unless they specifically want innovation
- Make it work everywhere or you failed
- Document decisions so you don't repeat mistakes
- MANDATORY: Research ALL backend endpoints before making ANY frontend changes
- Verify endpoint URLs, parameters, and response formats in actual Django codebase
- Test complete frontend-backend integration before considering work complete
- MANDATORY: Update ALL frontend documentation files after backend changes
- Synchronize docs/frontend.md, docs/lib-api.ts, and docs/types-api.ts
- Take immediate responsibility for integration failures without excuses
- MUST create frontend integration prompt after every backend change affecting API
- Include complete API endpoint information with all parameters and types
- Document all mandatory API rules (trailing slashes, HTTP methods, authentication)
- Never assume frontend developers have access to backend code
## 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
- Validate all endpoint URLs against the mandatory trailing slash rule
- **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")
- Individual rides reference BOTH the model (what product) and type (how it operates)
- 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
- **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>`
- Break down methods with high cognitive complexity (>15) into smaller, focused helper methods
- Extract logical operations into separate methods with descriptive names
- Use single responsibility principle - each method should have one clear purpose
- Prefer composition over deeply nested conditional logic
- Always handle None values explicitly to avoid type errors
- Use proper type annotations, including union types (e.g., `Polygon | None`)
- Structure API views with clear separation between parameter handling, business logic, and response building
- 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
- **Tracking**: All models use pghistory for change tracking and TrackedModel base class
- **Slugs**: Unique within scope (park slugs global, ride slugs within park, ride model slugs within manufacturer)
- **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
- **DOCUMENTATION**: After every change, it is MANDATORY to update docs/frontend.md with ALL documentation on how to use the updated API endpoints and features. It is MANDATORY to include any types in docs/types-api.ts for NextJS as the file would appear in `src/types/api.ts`. It is MANDATORY to include any new API endpoints in docs/lib-api.ts for NextJS as the file would appear in `/src/lib/api.ts`. Maintain accuracy and compliance in all technical documentation. Ensure API documentation matches backend URL routing expectations.
- **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**: 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. 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}/`. NEVER mix these domains - this is a fundamental and DANGEROUS business rule violation.
- **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.

View File

@@ -1,100 +1,17 @@
---
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
## 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
## Rich Choice Objects enforcement
- NEVER use Django tuple-based choices (e.g., `choices=[('VALUE', 'Label')]`) - ALWAYS use RichChoiceField
- 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
- All choices MUST include rich metadata (color, icon, description, css_class at minimum)
- Choice groups MUST be registered with global registry using `register_choices()` function
- Import choices in domain `__init__.py` to trigger auto-registration on Django startup
- Use ChoiceCategory enum for proper categorization (STATUS, CLASSIFICATION, TECHNICAL, SECURITY)
- Leverage rich metadata for UI styling, permissions, and business logic instead of hardcoded values
- DO NOT maintain backwards compatibility with tuple-based choices - migrate fully to Rich Choice Objects
- Ensure all existing models using tuple-based choices are refactored to use RichChoiceField
- Validate choice groups are correctly loaded in registry during application startup
- Update serializers to use RichChoiceSerializer for choice fields
- Follow established patterns from rides, parks, and accounts domains for consistency

View File

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

View File

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

83
.github/SECURITY.md vendored Normal file
View File

@@ -0,0 +1,83 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| latest | :white_check_mark: |
| < latest | :x: |
Only the latest version of ThrillWiki receives security updates.
## Reporting a Vulnerability
We take security vulnerabilities seriously. If you discover a security issue, please report it responsibly.
### How to Report
1. **Do not** create a public GitHub issue for security vulnerabilities
2. Email your report to the project maintainers
3. Include as much detail as possible:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Affected versions
- Any proof of concept (if available)
### What to Expect
- **Acknowledgment**: We will acknowledge receipt within 48 hours
- **Assessment**: We will assess the vulnerability and its impact
- **Updates**: We will keep you informed of our progress
- **Resolution**: We aim to resolve critical vulnerabilities within 7 days
- **Credit**: With your permission, we will credit you in our security advisories
### Scope
The following are in scope for security reports:
- ThrillWiki web application vulnerabilities
- Authentication and authorization issues
- Data exposure vulnerabilities
- Injection vulnerabilities (SQL, XSS, etc.)
- CSRF vulnerabilities
- Server-side request forgery (SSRF)
- Insecure direct object references
### Out of Scope
The following are out of scope:
- Denial of service attacks
- Social engineering attacks
- Physical security issues
- Issues in third-party applications or services
- Issues requiring physical access to a user's device
- Vulnerabilities in outdated versions
## Security Measures
ThrillWiki implements the following security measures:
- HTTPS enforcement with HSTS
- Content Security Policy
- XSS protection with input sanitization
- CSRF protection
- SQL injection prevention via ORM
- Rate limiting on authentication endpoints
- Secure session management
- JWT token rotation and blacklisting
For more details, see [docs/SECURITY.md](../docs/SECURITY.md).
## Security Updates
Security updates are released as soon as possible after a vulnerability is confirmed. We recommend:
1. Keep your installation up to date
2. Subscribe to release notifications
3. Review security advisories
## Contact
For security-related inquiries, please contact the project maintainers.

View File

@@ -27,7 +27,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 1

View File

@@ -26,7 +26,7 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 1

View File

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

View File

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

3
.gitignore vendored
View File

@@ -122,5 +122,4 @@ frontend/.env
django-forwardemail/
frontend/
frontend
.snapshots
uv.lock
.snapshots

73
.replit
View File

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

649
api_endpoints_curl_commands.sh Executable file
View File

@@ -0,0 +1,649 @@
#!/bin/bash
# ThrillWiki API Endpoints - Complete Curl Commands
# Generated from comprehensive URL analysis
# Base URL - adjust as needed for your environment
BASE_URL="http://localhost:8000"
# Command line options
SKIP_AUTH=false
ONLY_AUTH=false
SKIP_DOCS=false
HELP=false
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--skip-auth)
SKIP_AUTH=true
shift
;;
--only-auth)
ONLY_AUTH=true
shift
;;
--skip-docs)
SKIP_DOCS=true
shift
;;
--base-url)
BASE_URL="$2"
shift 2
;;
--help|-h)
HELP=true
shift
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
# Show help
if [ "$HELP" = true ]; then
echo "ThrillWiki API Endpoints Test Suite"
echo ""
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " --skip-auth Skip endpoints that require authentication"
echo " --only-auth Only test endpoints that require authentication"
echo " --skip-docs Skip API documentation endpoints (schema, swagger, redoc)"
echo " --base-url URL Set custom base URL (default: http://localhost:8000)"
echo " --help, -h Show this help message"
echo ""
echo "Examples:"
echo " $0 # Test all endpoints"
echo " $0 --skip-auth # Test only public endpoints"
echo " $0 --only-auth # Test only authenticated endpoints"
echo " $0 --skip-docs --skip-auth # Test only public non-documentation endpoints"
echo " $0 --base-url https://api.example.com # Use custom base URL"
exit 0
fi
# Validate conflicting options
if [ "$SKIP_AUTH" = true ] && [ "$ONLY_AUTH" = true ]; then
echo "Error: --skip-auth and --only-auth cannot be used together"
exit 1
fi
echo "=== ThrillWiki API Endpoints Test Suite ==="
echo "Base URL: $BASE_URL"
if [ "$SKIP_AUTH" = true ]; then
echo "Mode: Public endpoints only (skipping authentication required)"
elif [ "$ONLY_AUTH" = true ]; then
echo "Mode: Authenticated endpoints only"
else
echo "Mode: All endpoints"
fi
if [ "$SKIP_DOCS" = true ]; then
echo "Skipping: API documentation endpoints"
fi
echo ""
# Helper function to check if we should run an endpoint
should_run_endpoint() {
local requires_auth=$1
local is_docs=$2
# Skip docs if requested
if [ "$SKIP_DOCS" = true ] && [ "$is_docs" = true ]; then
return 1
fi
# Skip auth endpoints if requested
if [ "$SKIP_AUTH" = true ] && [ "$requires_auth" = true ]; then
return 1
fi
# Only run auth endpoints if requested
if [ "$ONLY_AUTH" = true ] && [ "$requires_auth" = false ]; then
return 1
fi
return 0
}
# Counter for endpoint numbering
ENDPOINT_NUM=1
# ============================================================================
# AUTHENTICATION ENDPOINTS (/api/v1/auth/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo "=== AUTHENTICATION ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. Login"
curl -X POST "$BASE_URL/api/v1/auth/login/" \
-H "Content-Type: application/json" \
-d '{"username": "testuser", "password": "testpass"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Signup"
curl -X POST "$BASE_URL/api/v1/auth/signup/" \
-H "Content-Type: application/json" \
-d '{"username": "newuser", "email": "test@example.com", "password": "newpass123"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Logout"
curl -X POST "$BASE_URL/api/v1/auth/logout/" \
-H "Content-Type: application/json"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Password Reset"
curl -X POST "$BASE_URL/api/v1/auth/password/reset/" \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Social Providers"
curl -X GET "$BASE_URL/api/v1/auth/providers/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Auth Status"
curl -X GET "$BASE_URL/api/v1/auth/status/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Current User"
curl -X GET "$BASE_URL/api/v1/auth/user/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Password Change"
curl -X POST "$BASE_URL/api/v1/auth/password/change/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"old_password": "oldpass", "new_password": "newpass123"}'
((ENDPOINT_NUM++))
fi
# ============================================================================
# HEALTH CHECK ENDPOINTS (/api/v1/health/)
# ============================================================================
if should_run_endpoint false false; then
echo -e "\n\n=== HEALTH CHECK ENDPOINTS ==="
echo "$ENDPOINT_NUM. Health Check"
curl -X GET "$BASE_URL/api/v1/health/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Simple Health"
curl -X GET "$BASE_URL/api/v1/health/simple/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Performance Metrics"
curl -X GET "$BASE_URL/api/v1/health/performance/"
((ENDPOINT_NUM++))
fi
# ============================================================================
# TRENDING SYSTEM ENDPOINTS (/api/v1/trending/)
# ============================================================================
if should_run_endpoint false false; then
echo -e "\n\n=== TRENDING SYSTEM ENDPOINTS ==="
echo "$ENDPOINT_NUM. Trending Content"
curl -X GET "$BASE_URL/api/v1/trending/content/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. New Content"
curl -X GET "$BASE_URL/api/v1/trending/new/"
((ENDPOINT_NUM++))
fi
# ============================================================================
# STATISTICS ENDPOINTS (/api/v1/stats/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== STATISTICS ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. Statistics"
curl -X GET "$BASE_URL/api/v1/stats/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Recalculate Statistics"
curl -X POST "$BASE_URL/api/v1/stats/recalculate/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
fi
# ============================================================================
# RANKING SYSTEM ENDPOINTS (/api/v1/rankings/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== RANKING SYSTEM ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. List Rankings"
curl -X GET "$BASE_URL/api/v1/rankings/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. List Rankings with Filters"
curl -X GET "$BASE_URL/api/v1/rankings/?category=RC&min_riders=10&ordering=rank"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ranking Detail"
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ranking History"
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/history/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ranking Statistics"
curl -X GET "$BASE_URL/api/v1/rankings/statistics/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ranking Comparisons"
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/comparisons/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Trigger Ranking Calculation"
curl -X POST "$BASE_URL/api/v1/rankings/calculate/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"category": "RC"}'
((ENDPOINT_NUM++))
fi
# ============================================================================
# PARKS API ENDPOINTS (/api/v1/parks/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== PARKS API ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. List Parks"
curl -X GET "$BASE_URL/api/v1/parks/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park Filter Options"
curl -X GET "$BASE_URL/api/v1/parks/filter-options/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park Company Search"
curl -X GET "$BASE_URL/api/v1/parks/search/companies/?q=disney"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park Search Suggestions"
curl -X GET "$BASE_URL/api/v1/parks/search-suggestions/?q=magic"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park Detail"
curl -X GET "$BASE_URL/api/v1/parks/1/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. List Park Photos"
curl -X GET "$BASE_URL/api/v1/parks/1/photos/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park Photo Detail"
curl -X GET "$BASE_URL/api/v1/parks/1/photos/1/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Create Park"
curl -X POST "$BASE_URL/api/v1/parks/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "Test Park", "location": "Test City"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Park"
curl -X PUT "$BASE_URL/api/v1/parks/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "Updated Park Name"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Park"
curl -X DELETE "$BASE_URL/api/v1/parks/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Create Park Photo"
curl -X POST "$BASE_URL/api/v1/parks/1/photos/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-F "image=@/path/to/photo.jpg" \
-F "caption=Test photo"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Park Photo"
curl -X PUT "$BASE_URL/api/v1/parks/1/photos/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"caption": "Updated caption"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Park Photo"
curl -X DELETE "$BASE_URL/api/v1/parks/1/photos/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
fi
# ============================================================================
# RIDES API ENDPOINTS (/api/v1/rides/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== RIDES API ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. List Rides"
curl -X GET "$BASE_URL/api/v1/rides/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Filter Options"
curl -X GET "$BASE_URL/api/v1/rides/filter-options/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Company Search"
curl -X GET "$BASE_URL/api/v1/rides/search/companies/?q=intamin"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Model Search"
curl -X GET "$BASE_URL/api/v1/rides/search/ride-models/?q=giga"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Search Suggestions"
curl -X GET "$BASE_URL/api/v1/rides/search-suggestions/?q=millennium"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Detail"
curl -X GET "$BASE_URL/api/v1/rides/1/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. List Ride Photos"
curl -X GET "$BASE_URL/api/v1/rides/1/photos/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Photo Detail"
curl -X GET "$BASE_URL/api/v1/rides/1/photos/1/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Create Ride"
curl -X POST "$BASE_URL/api/v1/rides/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "Test Coaster", "category": "RC", "park": 1}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Ride"
curl -X PUT "$BASE_URL/api/v1/rides/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "Updated Ride Name"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Ride"
curl -X DELETE "$BASE_URL/api/v1/rides/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Create Ride Photo"
curl -X POST "$BASE_URL/api/v1/rides/1/photos/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-F "image=@/path/to/photo.jpg" \
-F "caption=Test ride photo"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Ride Photo"
curl -X PUT "$BASE_URL/api/v1/rides/1/photos/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"caption": "Updated ride photo caption"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Ride Photo"
curl -X DELETE "$BASE_URL/api/v1/rides/1/photos/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
fi
# ============================================================================
# ACCOUNTS API ENDPOINTS (/api/v1/accounts/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== ACCOUNTS API ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. List User Profiles"
curl -X GET "$BASE_URL/api/v1/accounts/profiles/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. User Profile Detail"
curl -X GET "$BASE_URL/api/v1/accounts/profiles/1/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. List Top Lists"
curl -X GET "$BASE_URL/api/v1/accounts/toplists/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Top List Detail"
curl -X GET "$BASE_URL/api/v1/accounts/toplists/1/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. List Top List Items"
curl -X GET "$BASE_URL/api/v1/accounts/toplist-items/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Top List Item Detail"
curl -X GET "$BASE_URL/api/v1/accounts/toplist-items/1/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Update User Profile"
curl -X PUT "$BASE_URL/api/v1/accounts/profiles/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"bio": "Updated bio"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Create Top List"
curl -X POST "$BASE_URL/api/v1/accounts/toplists/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "My Top Coasters", "description": "My favorite roller coasters"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Top List"
curl -X PUT "$BASE_URL/api/v1/accounts/toplists/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "Updated Top List Name"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Top List"
curl -X DELETE "$BASE_URL/api/v1/accounts/toplists/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Create Top List Item"
curl -X POST "$BASE_URL/api/v1/accounts/toplist-items/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"toplist": 1, "ride": 1, "position": 1}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Top List Item"
curl -X PUT "$BASE_URL/api/v1/accounts/toplist-items/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"position": 2}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Top List Item"
curl -X DELETE "$BASE_URL/api/v1/accounts/toplist-items/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
fi
# ============================================================================
# HISTORY API ENDPOINTS (/api/v1/history/)
# ============================================================================
if should_run_endpoint false false; then
echo -e "\n\n=== HISTORY API ENDPOINTS ==="
echo "$ENDPOINT_NUM. Park History List"
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park History Detail"
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/detail/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride History List"
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/rides/ride-slug/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride History Detail"
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/rides/ride-slug/detail/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Unified Timeline"
curl -X GET "$BASE_URL/api/v1/history/timeline/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Unified Timeline Detail"
curl -X GET "$BASE_URL/api/v1/history/timeline/1/"
((ENDPOINT_NUM++))
fi
# ============================================================================
# EMAIL API ENDPOINTS (/api/v1/email/)
# ============================================================================
if should_run_endpoint true false; then
echo -e "\n\n=== EMAIL API ENDPOINTS ==="
echo "$ENDPOINT_NUM. Send Email"
curl -X POST "$BASE_URL/api/v1/email/send/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"to": "recipient@example.com", "subject": "Test", "message": "Test message"}'
((ENDPOINT_NUM++))
fi
# ============================================================================
# CORE API ENDPOINTS (/api/v1/core/)
# ============================================================================
if should_run_endpoint false false; then
echo -e "\n\n=== CORE API ENDPOINTS ==="
echo "$ENDPOINT_NUM. Entity Fuzzy Search"
curl -X GET "$BASE_URL/api/v1/core/entities/search/?q=disney"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Entity Not Found"
curl -X POST "$BASE_URL/api/v1/core/entities/not-found/" \
-H "Content-Type: application/json" \
-d '{"query": "nonexistent park", "type": "park"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Entity Suggestions"
curl -X GET "$BASE_URL/api/v1/core/entities/suggestions/?q=magic"
((ENDPOINT_NUM++))
fi
# ============================================================================
# MAPS API ENDPOINTS (/api/v1/maps/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== MAPS API ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. Map Locations"
curl -X GET "$BASE_URL/api/v1/maps/locations/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Map Location Detail"
curl -X GET "$BASE_URL/api/v1/maps/locations/park/1/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Map Search"
curl -X GET "$BASE_URL/api/v1/maps/search/?q=disney"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Map Bounds Query"
curl -X GET "$BASE_URL/api/v1/maps/bounds/?north=40.7&south=40.6&east=-73.9&west=-74.0"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Map Statistics"
curl -X GET "$BASE_URL/api/v1/maps/stats/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Map Cache Status"
curl -X GET "$BASE_URL/api/v1/maps/cache/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Invalidate Map Cache"
curl -X POST "$BASE_URL/api/v1/maps/cache/invalidate/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
fi
# ============================================================================
# API DOCUMENTATION ENDPOINTS
# ============================================================================
if should_run_endpoint false true; then
echo -e "\n\n=== API DOCUMENTATION ENDPOINTS ==="
echo "$ENDPOINT_NUM. OpenAPI Schema"
curl -X GET "$BASE_URL/api/schema/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Swagger UI"
curl -X GET "$BASE_URL/api/docs/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. ReDoc"
curl -X GET "$BASE_URL/api/redoc/"
((ENDPOINT_NUM++))
fi
# ============================================================================
# HEALTH CHECK (Django Health Check)
# ============================================================================
if should_run_endpoint false false; then
echo -e "\n\n=== DJANGO HEALTH CHECK ==="
echo "$ENDPOINT_NUM. Django Health Check"
curl -X GET "$BASE_URL/health/"
((ENDPOINT_NUM++))
fi
echo -e "\n\n=== END OF API ENDPOINTS TEST SUITE ==="
echo "Total endpoints tested: $((ENDPOINT_NUM - 1))"
echo ""
echo "Notes:"
echo "- Replace YOUR_TOKEN_HERE with actual authentication tokens"
echo "- Replace /path/to/photo.jpg with actual file paths for photo uploads"
echo "- Replace numeric IDs (1, 2, etc.) with actual resource IDs"
echo "- Replace slug placeholders (park-slug, ride-slug) with actual slugs"
echo "- Adjust BASE_URL for your environment (localhost:8000, staging, production)"
echo ""
echo "Authentication required endpoints are marked with Authorization header"
echo "File upload endpoints use multipart/form-data (-F flag)"
echo "JSON endpoints use application/json content type"

View File

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

View File

@@ -1,369 +1,51 @@
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 (
# Import models from the backend location
from backend.apps.accounts.models import (
User,
UserProfile,
EmailVerification,
PasswordReset,
TopList,
TopListItem,
)
class UserProfileInline(admin.StackedInline[UserProfile, admin.options.AdminSite]):
model = UserProfile
can_delete = False
verbose_name_plural = "Profile"
fieldsets = (
(
"Personal Info",
{"fields": ("display_name", "avatar", "pronouns", "bio")},
),
(
"Social Media",
{"fields": ("twitter", "instagram", "youtube", "discord")},
),
(
"Ride Credits",
{
"fields": (
"coaster_credits",
"dark_ride_credits",
"flat_ride_credits",
"water_ride_credits",
)
},
),
)
class TopListItemInline(admin.TabularInline[TopListItem]):
model = TopListItem
extra = 1
fields = ("content_type", "object_id", "rank", "notes")
ordering = ("rank",)
@admin.register(User)
class CustomUserAdmin(DjangoUserAdmin[User]):
list_display = (
"username",
"email",
"get_avatar",
"get_status",
"role",
"date_joined",
"last_login",
"get_credits",
)
list_filter = (
"is_active",
"is_staff",
"role",
"is_banned",
"groups",
"date_joined",
)
search_fields = ("username", "email")
ordering = ("-date_joined",)
actions = [
"activate_users",
"deactivate_users",
"ban_users",
"unban_users",
]
inlines: list[type[admin.StackedInline[UserProfile]]] = [UserProfileInline]
class CustomUserAdmin(UserAdmin):
list_display = ('username', 'email', 'user_id', 'role', 'is_active', 'is_staff', 'date_joined')
list_filter = ('role', 'is_active', 'is_staff', 'is_banned', 'date_joined')
search_fields = ('username', 'email', 'user_id', 'display_name')
readonly_fields = ('user_id', 'date_joined', 'last_login')
fieldsets = (
(None, {"fields": ("username", "password")}),
("Personal info", {"fields": ("email", "pending_email")}),
(
"Roles and Permissions",
{
"fields": ("role", "groups", "user_permissions"),
"description": (
"Role determines group membership. Groups determine permissions."
),
},
),
(
"Status",
{
"fields": ("is_active", "is_staff", "is_superuser"),
"description": "These are automatically managed based on role.",
},
),
(
"Ban Status",
{
"fields": ("is_banned", "ban_reason", "ban_date"),
},
),
(
"Preferences",
{
"fields": ("theme_preference",),
},
),
("Important dates", {"fields": ("last_login", "date_joined")}),
(None, {'fields': ('username', 'password')}),
('Personal info', {'fields': ('email', 'display_name', 'user_id')}),
('Permissions', {'fields': ('role', 'is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
('Important dates', {'fields': ('last_login', 'date_joined')}),
('Moderation', {'fields': ('is_banned', 'ban_reason', 'ban_date')}),
('Preferences', {'fields': ('theme_preference', 'privacy_level')}),
('Notifications', {'fields': ('email_notifications', 'push_notifications')}),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": (
"username",
"email",
"password1",
"password2",
"role",
),
},
),
)
@admin.display(description="Avatar")
def get_avatar(self, obj: User) -> str:
profile = getattr(obj, "profile", None)
if profile and getattr(profile, "avatar", None):
return format_html(
'<img src="{0}" width="30" height="30" style="border-radius:50%;" />',
getattr(profile.avatar, "url", ""), # type: ignore
)
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
)
@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")
@admin.display(description="Ride Credits")
def get_credits(self, obj: User) -> str:
try:
profile = getattr(obj, "profile", None)
if not profile:
return "-"
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),
)
except UserProfile.DoesNotExist:
return "-"
@admin.action(description="Activate selected users")
def activate_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
queryset.update(is_active=True)
@admin.action(description="Deactivate selected users")
def deactivate_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
queryset.update(is_active=False)
@admin.action(description="Ban selected users")
def ban_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
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:
queryset.update(is_banned=False, ban_date=None, ban_reason="")
def save_model(
self,
request: HttpRequest,
obj: User,
form: Any,
change: bool
) -> None:
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 group:
obj.groups.add(group) # type: ignore[attr-defined]
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin[UserProfile]):
list_display = (
"user",
"display_name",
"coaster_credits",
"dark_ride_credits",
"flat_ride_credits",
"water_ride_credits",
)
list_filter = (
"coaster_credits",
"dark_ride_credits",
"flat_ride_credits",
"water_ride_credits",
)
search_fields = ("user__username", "user__email", "display_name", "bio")
class UserProfileAdmin(admin.ModelAdmin):
list_display = ('user', 'profile_id', 'display_name', 'coaster_credits', 'dark_ride_credits')
list_filter = ('user__role', 'user__is_active')
search_fields = ('user__username', 'user__email', 'profile_id', 'display_name')
readonly_fields = ('profile_id',)
fieldsets = (
(
"User Information",
{"fields": ("user", "display_name", "avatar", "pronouns", "bio")},
),
(
"Social Media",
{"fields": ("twitter", "instagram", "youtube", "discord")},
),
(
"Ride Credits",
{
"fields": (
"coaster_credits",
"dark_ride_credits",
"flat_ride_credits",
"water_ride_credits",
)
},
),
(None, {'fields': ('user', 'profile_id', 'display_name')}),
('Profile Info', {'fields': ('avatar', 'pronouns', 'bio')}),
('Social Media', {'fields': ('twitter', 'instagram', 'youtube', 'discord')}),
('Ride Statistics', {'fields': ('coaster_credits', 'dark_ride_credits', 'flat_ride_credits', 'water_ride_credits')}),
)
@admin.register(EmailVerification)
class EmailVerificationAdmin(admin.ModelAdmin[EmailVerification]):
list_display = ("user", "created_at", "last_sent", "is_expired")
list_filter = ("created_at", "last_sent")
search_fields = ("user__username", "user__email", "token")
readonly_fields = ("created_at", "last_sent")
fieldsets = (
("Verification Details", {"fields": ("user", "token")}),
("Timing", {"fields": ("created_at", "last_sent")}),
)
@admin.display(description="Status")
def is_expired(self, obj: EmailVerification) -> str:
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")
@admin.register(TopList)
class TopListAdmin(admin.ModelAdmin[TopList]):
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]
fieldsets = (
(
"Basic Information",
{"fields": ("user", "title", "category", "description")},
),
(
"Timestamps",
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
),
)
readonly_fields = ("created_at", "updated_at")
@admin.register(TopListItem)
class TopListItemAdmin(admin.ModelAdmin[TopListItem]):
list_display = ("top_list", "content_type", "object_id", "rank")
list_filter = ("top_list__category", "rank")
search_fields = ("top_list__title", "notes")
ordering = ("top_list", "rank")
fieldsets = (
("List Information", {"fields": ("top_list", "rank")}),
("Item Details", {"fields": ("content_type", "object_id", "notes")}),
)
@admin.register(PasswordReset)
class PasswordResetAdmin(admin.ModelAdmin[PasswordReset]):
"""Admin interface for password reset tokens"""
list_display = (
"user",
"created_at",
"expires_at",
"is_expired",
"used",
)
list_filter = (
"used",
"created_at",
"expires_at",
)
search_fields = (
"user__username",
"user__email",
"token",
)
readonly_fields = (
"token",
"created_at",
"expires_at",
)
date_hierarchy = "created_at"
ordering = ("-created_at",)
fieldsets = (
(
"Reset Details",
{
"fields": (
"user",
"token",
"used",
)
},
),
(
"Timing",
{
"fields": (
"created_at",
"expires_at",
)
},
),
)
@admin.display(description="Status", boolean=True)
def is_expired(self, obj: PasswordReset) -> str:
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")
def has_add_permission(self, request: HttpRequest) -> bool:
"""Disable manual creation of password reset tokens"""
return False
def has_change_permission(self, request: HttpRequest, obj: Any = None) -> bool:
"""Allow viewing but restrict editing of password reset tokens"""
return getattr(request.user, "is_superuser", False)
class EmailVerificationAdmin(admin.ModelAdmin):
list_display = ('user', 'token', 'created_at', 'last_sent')
list_filter = ('created_at', 'last_sent')
search_fields = ('user__username', 'user__email', 'token')
readonly_fields = ('token', 'created_at', 'last_sent')

View File

@@ -1,108 +0,0 @@
from django.core.management.base import BaseCommand
from django.db import connection
from django.contrib.auth.hashers import make_password
import uuid
class Command(BaseCommand):
help = "Reset database and create admin user"
def handle(self, *args, **options):
self.stdout.write("Resetting database...")
# Drop all tables
with connection.cursor() as cursor:
cursor.execute(
"""
DO $$ DECLARE
r RECORD;
BEGIN
FOR r IN (
SELECT tablename FROM pg_tables
WHERE schemaname = current_schema()
) LOOP
EXECUTE 'DROP TABLE IF EXISTS ' || \
quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END $$;
"""
)
# Reset sequences
cursor.execute(
"""
DO $$ DECLARE
r RECORD;
BEGIN
FOR r IN (
SELECT sequencename FROM pg_sequences
WHERE schemaname = current_schema()
) LOOP
EXECUTE 'ALTER SEQUENCE ' || \
quote_ident(r.sequencename) || ' RESTART WITH 1';
END LOOP;
END $$;
"""
)
self.stdout.write("All tables dropped and sequences reset.")
# Run migrations
from django.core.management import call_command
call_command("migrate")
self.stdout.write("Migrations applied.")
# Create superuser using raw SQL
try:
with connection.cursor() as cursor:
# Create user
user_id = str(uuid.uuid4())[:10]
cursor.execute(
"""
INSERT INTO accounts_user (
username, password, email, is_superuser, is_staff,
is_active, date_joined, user_id, first_name,
last_name, role, is_banned, ban_reason,
theme_preference
) VALUES (
'admin', %s, 'admin@thrillwiki.com', true, true,
true, NOW(), %s, '', '', 'SUPERUSER', false, '',
'light'
) RETURNING id;
""",
[make_password("admin"), user_id],
)
result = cursor.fetchone()
if result is None:
raise Exception("Failed to create user - no ID returned")
user_db_id = result[0]
# Create profile
profile_id = str(uuid.uuid4())[:10]
cursor.execute(
"""
INSERT INTO accounts_userprofile (
profile_id, display_name, pronouns, bio,
twitter, instagram, youtube, discord,
coaster_credits, dark_ride_credits,
flat_ride_credits, water_ride_credits,
user_id, avatar
) VALUES (
%s, 'Admin', 'they/them', 'ThrillWiki Administrator',
'', '', '', '',
0, 0, 0, 0,
%s, ''
);
""",
[profile_id, user_db_id],
)
self.stdout.write("Superuser created.")
except Exception as e:
self.stdout.write(self.style.ERROR(f"Error creating superuser: {str(e)}"))
raise
self.stdout.write(self.style.SUCCESS("Database reset complete."))

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,6 +0,0 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.core"

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,19 +0,0 @@
from django.views.generic.list import MultipleObjectMixin
class HTMXFilterableMixin(MultipleObjectMixin):
"""
A mixin that provides filtering capabilities for HTMX requests.
"""
filter_class = None
def get_queryset(self):
queryset = super().get_queryset()
self.filterset = self.filter_class(self.request.GET, queryset=queryset)
return self.filterset.qs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["filter"] = self.filterset
return context

View File

@@ -1,26 +0,0 @@
"""
Core app URL configuration.
"""
from django.urls import path, include
from .views.entity_search import (
EntityFuzzySearchView,
EntityNotFoundView,
QuickEntitySuggestionView,
)
app_name = "core"
# Entity search endpoints
entity_patterns = [
path("search/", EntityFuzzySearchView.as_view(), name="entity_fuzzy_search"),
path("not-found/", EntityNotFoundView.as_view(), name="entity_not_found"),
path(
"suggestions/", QuickEntitySuggestionView.as_view(), name="entity_suggestions"
),
]
urlpatterns = [
# Entity fuzzy matching and search endpoints
path("entities/", include(entity_patterns)),
]

View File

@@ -1 +0,0 @@
# URLs package for core app

View File

@@ -1 +0,0 @@
# Core utilities

View File

@@ -1,62 +0,0 @@
from typing import Any, Dict, Optional, Type
from django.shortcuts import redirect
from django.urls import reverse
from django.views.generic import DetailView
from django.views import View
from django.http import HttpRequest, HttpResponse
from django.db.models import Model
class SlugRedirectMixin(View):
"""
Mixin that handles redirects for old slugs.
Requires the model to inherit from SluggedModel and view to inherit from DetailView.
"""
model: Optional[Type[Model]] = None
slug_url_kwarg: str = "slug"
object: Optional[Model] = None
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
# Only apply slug redirect logic to DetailViews
if not isinstance(self, DetailView):
return super().dispatch(request, *args, **kwargs)
# Get the object using current or historical slug
try:
self.object = self.get_object() # type: ignore
# Check if we used an old slug
current_slug = kwargs.get(self.slug_url_kwarg)
if current_slug and current_slug != getattr(self.object, "slug", None):
# Get the URL pattern name from the view
url_pattern = self.get_redirect_url_pattern()
# Build kwargs for reverse()
reverse_kwargs = self.get_redirect_url_kwargs()
# Redirect to the current slug URL
return redirect(
reverse(url_pattern, kwargs=reverse_kwargs), permanent=True
)
return super().dispatch(request, *args, **kwargs)
except (AttributeError, Exception) as e: # type: ignore
if self.model and hasattr(self.model, "DoesNotExist"):
if isinstance(e, self.model.DoesNotExist): # type: ignore
return super().dispatch(request, *args, **kwargs)
return super().dispatch(request, *args, **kwargs)
def get_redirect_url_pattern(self) -> str:
"""
Get the URL pattern name for redirects.
Should be overridden by subclasses.
"""
raise NotImplementedError(
"Subclasses must implement get_redirect_url_pattern()"
)
def get_redirect_url_kwargs(self) -> Dict[str, Any]:
"""
Get the kwargs for reverse() when redirecting.
Should be overridden by subclasses if they need custom kwargs.
"""
if not self.object:
return {}
return {self.slug_url_kwarg: getattr(self.object, "slug", "")}

View File

@@ -1,7 +0,0 @@
from django.apps import AppConfig
class ModerationConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.moderation"
verbose_name = "Content Moderation"

File diff suppressed because it is too large Load Diff

View File

@@ -1,349 +0,0 @@
from django.test import TestCase, Client
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes.models import ContentType
from django.core.files.uploadedfile import SimpleUploadedFile
from django.http import JsonResponse, HttpRequest
from .models import EditSubmission
from .mixins import (
EditSubmissionMixin,
PhotoSubmissionMixin,
ModeratorRequiredMixin,
AdminRequiredMixin,
InlineEditMixin,
HistoryMixin,
)
from apps.parks.models import Company as Operator
from django.views.generic import DetailView
from django.test import RequestFactory
import json
User = get_user_model()
class TestView(
EditSubmissionMixin,
PhotoSubmissionMixin,
InlineEditMixin,
HistoryMixin,
DetailView,
):
model = Operator
template_name = "test.html"
pk_url_kwarg = "pk"
slug_url_kwarg = "slug"
def get_context_data(self, **kwargs):
if not hasattr(self, "object"):
self.object = self.get_object()
return super().get_context_data(**kwargs)
def setup(self, request: HttpRequest, *args, **kwargs):
super().setup(request, *args, **kwargs)
self.request = request
class ModerationMixinsTests(TestCase):
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
# Create users with different roles
self.user = User.objects.create_user(
username="testuser",
email="test@example.com",
password="testpass123",
)
self.moderator = User.objects.create_user(
username="moderator",
email="moderator@example.com",
password="modpass123",
role="MODERATOR",
)
self.admin = User.objects.create_user(
username="admin",
email="admin@example.com",
password="adminpass123",
role="ADMIN",
)
# Create test company
self.operator = Operator.objects.create(
name="Test Operator",
website="http://example.com",
description="Test Description",
)
def test_edit_submission_mixin_unauthenticated(self):
"""Test edit submission when not logged in"""
view = TestView()
request = self.factory.post(f"/test/{self.operator.pk}/")
request.user = AnonymousUser()
view.setup(request, pk=self.operator.pk)
view.kwargs = {"pk": self.operator.pk}
response = view.handle_edit_submission(request, {})
self.assertIsInstance(response, JsonResponse)
self.assertEqual(response.status_code, 403)
def test_edit_submission_mixin_no_changes(self):
"""Test edit submission with no changes"""
view = TestView()
request = self.factory.post(
f"/test/{self.operator.pk}/",
data=json.dumps({}),
content_type="application/json",
)
request.user = self.user
view.setup(request, pk=self.operator.pk)
view.kwargs = {"pk": self.operator.pk}
response = view.post(request)
self.assertIsInstance(response, JsonResponse)
self.assertEqual(response.status_code, 400)
def test_edit_submission_mixin_invalid_json(self):
"""Test edit submission with invalid JSON"""
view = TestView()
request = self.factory.post(
f"/test/{self.operator.pk}/",
data="invalid json",
content_type="application/json",
)
request.user = self.user
view.setup(request, pk=self.operator.pk)
view.kwargs = {"pk": self.operator.pk}
response = view.post(request)
self.assertIsInstance(response, JsonResponse)
self.assertEqual(response.status_code, 400)
def test_edit_submission_mixin_regular_user(self):
"""Test edit submission as regular user"""
view = TestView()
request = self.factory.post(f"/test/{self.operator.pk}/")
request.user = self.user
view.setup(request, pk=self.operator.pk)
view.kwargs = {"pk": self.operator.pk}
changes = {"name": "New Name"}
response = view.handle_edit_submission(
request, changes, "Test reason", "Test source"
)
self.assertIsInstance(response, JsonResponse)
self.assertEqual(response.status_code, 200)
data = json.loads(response.content.decode())
self.assertFalse(data["auto_approved"])
def test_edit_submission_mixin_moderator(self):
"""Test edit submission as moderator"""
view = TestView()
request = self.factory.post(f"/test/{self.operator.pk}/")
request.user = self.moderator
view.setup(request, pk=self.operator.pk)
view.kwargs = {"pk": self.operator.pk}
changes = {"name": "New Name"}
response = view.handle_edit_submission(
request, changes, "Test reason", "Test source"
)
self.assertIsInstance(response, JsonResponse)
self.assertEqual(response.status_code, 200)
data = json.loads(response.content.decode())
self.assertTrue(data["auto_approved"])
def test_photo_submission_mixin_unauthenticated(self):
"""Test photo submission when not logged in"""
view = TestView()
view.kwargs = {"pk": self.operator.pk}
view.object = self.operator
request = self.factory.post(
f"/test/{self.operator.pk}/", data={}, format="multipart"
)
request.user = AnonymousUser()
view.setup(request, pk=self.operator.pk)
response = view.handle_photo_submission(request)
self.assertIsInstance(response, JsonResponse)
self.assertEqual(response.status_code, 403)
def test_photo_submission_mixin_no_photo(self):
"""Test photo submission with no photo"""
view = TestView()
view.kwargs = {"pk": self.operator.pk}
view.object = self.operator
request = self.factory.post(
f"/test/{self.operator.pk}/", data={}, format="multipart"
)
request.user = self.user
view.setup(request, pk=self.operator.pk)
response = view.handle_photo_submission(request)
self.assertIsInstance(response, JsonResponse)
self.assertEqual(response.status_code, 400)
def test_photo_submission_mixin_regular_user(self):
"""Test photo submission as regular user"""
view = TestView()
view.kwargs = {"pk": self.operator.pk}
view.object = self.operator
# Create a test photo file
photo = SimpleUploadedFile(
"test.gif",
b"GIF87a\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00ccc,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;",
content_type="image/gif",
)
request = self.factory.post(
f"/test/{self.operator.pk}/",
data={
"photo": photo,
"caption": "Test Photo",
"date_taken": "2024-01-01",
},
format="multipart",
)
request.user = self.user
view.setup(request, pk=self.operator.pk)
response = view.handle_photo_submission(request)
self.assertIsInstance(response, JsonResponse)
self.assertEqual(response.status_code, 200)
data = json.loads(response.content.decode())
self.assertFalse(data["auto_approved"])
def test_photo_submission_mixin_moderator(self):
"""Test photo submission as moderator"""
view = TestView()
view.kwargs = {"pk": self.operator.pk}
view.object = self.operator
# Create a test photo file
photo = SimpleUploadedFile(
"test.gif",
b"GIF87a\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00ccc,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;",
content_type="image/gif",
)
request = self.factory.post(
f"/test/{self.operator.pk}/",
data={
"photo": photo,
"caption": "Test Photo",
"date_taken": "2024-01-01",
},
format="multipart",
)
request.user = self.moderator
view.setup(request, pk=self.operator.pk)
response = view.handle_photo_submission(request)
self.assertIsInstance(response, JsonResponse)
self.assertEqual(response.status_code, 200)
data = json.loads(response.content.decode())
self.assertTrue(data["auto_approved"])
def test_moderator_required_mixin(self):
"""Test moderator required mixin"""
class TestModeratorView(ModeratorRequiredMixin):
pass
view = TestModeratorView()
# Test unauthenticated user
request = self.factory.get("/test/")
request.user = AnonymousUser()
view.request = request
self.assertFalse(view.test_func())
# Test regular user
request.user = self.user
view.request = request
self.assertFalse(view.test_func())
# Test moderator
request.user = self.moderator
view.request = request
self.assertTrue(view.test_func())
# Test admin
request.user = self.admin
view.request = request
self.assertTrue(view.test_func())
def test_admin_required_mixin(self):
"""Test admin required mixin"""
class TestAdminView(AdminRequiredMixin):
pass
view = TestAdminView()
# Test unauthenticated user
request = self.factory.get("/test/")
request.user = AnonymousUser()
view.request = request
self.assertFalse(view.test_func())
# Test regular user
request.user = self.user
view.request = request
self.assertFalse(view.test_func())
# Test moderator
request.user = self.moderator
view.request = request
self.assertFalse(view.test_func())
# Test admin
request.user = self.admin
view.request = request
self.assertTrue(view.test_func())
def test_inline_edit_mixin(self):
"""Test inline edit mixin"""
view = TestView()
view.kwargs = {"pk": self.operator.pk}
view.object = self.operator
# Test unauthenticated user
request = self.factory.get(f"/test/{self.operator.pk}/")
request.user = AnonymousUser()
view.setup(request, pk=self.operator.pk)
context = view.get_context_data()
self.assertNotIn("can_edit", context)
# Test regular user
request.user = self.user
view.setup(request, pk=self.operator.pk)
context = view.get_context_data()
self.assertTrue(context["can_edit"])
self.assertFalse(context["can_auto_approve"])
# Test moderator
request.user = self.moderator
view.setup(request, pk=self.operator.pk)
context = view.get_context_data()
self.assertTrue(context["can_edit"])
self.assertTrue(context["can_auto_approve"])
def test_history_mixin(self):
"""Test history mixin"""
view = TestView()
view.kwargs = {"pk": self.operator.pk}
view.object = self.operator
request = self.factory.get(f"/test/{self.operator.pk}/")
request.user = self.user
view.setup(request, pk=self.operator.pk)
# Create some edit submissions
EditSubmission.objects.create(
user=self.user,
content_type=ContentType.objects.get_for_model(Operator),
object_id=getattr(self.operator, "id", None),
submission_type="EDIT",
changes={"name": "New Name"},
status="APPROVED",
)
context = view.get_context_data()
self.assertIn("history", context)
self.assertIn("edit_submissions", context)
self.assertEqual(len(context["edit_submissions"]), 1)

View File

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

View File

@@ -1,9 +0,0 @@
from django.apps import AppConfig
class ParksConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.parks"
def ready(self):
import apps.parks.signals # noqa: F401 - Register signals

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -1,228 +0,0 @@
"""
Services for park-related business logic.
Following Django styleguide pattern for business logic encapsulation.
"""
from typing import Optional, Dict, Any, TYPE_CHECKING
from django.db import transaction
from django.db.models import Q
if TYPE_CHECKING:
from django.contrib.auth.models import AbstractUser
from ..models import Park, ParkArea
from .location_service import ParkLocationService
class ParkService:
"""Service for managing park operations."""
@staticmethod
def create_park(
*,
name: str,
description: str = "",
status: str = "OPERATING",
operator_id: Optional[int] = None,
property_owner_id: Optional[int] = None,
opening_date: Optional[str] = None,
closing_date: Optional[str] = None,
operating_season: str = "",
size_acres: Optional[float] = None,
website: str = "",
location_data: Optional[Dict[str, Any]] = None,
created_by: Optional["AbstractUser"] = None,
) -> Park:
"""
Create a new park with validation and location handling.
Args:
name: Park name
description: Park description
status: Operating status
operator_id: ID of operating company
property_owner_id: ID of property owner company
opening_date: Opening date
closing_date: Closing date
operating_season: Operating season description
size_acres: Park size in acres
website: Park website URL
location_data: Dictionary containing location information
created_by: User creating the park
Returns:
Created Park instance
Raises:
ValidationError: If park data is invalid
"""
with transaction.atomic():
# Create park instance
park = Park(
name=name,
description=description,
status=status,
opening_date=opening_date,
closing_date=closing_date,
operating_season=operating_season,
size_acres=size_acres,
website=website,
)
# Set foreign key relationships if provided
if operator_id:
from apps.parks.models import Company
park.operator = Company.objects.get(id=operator_id)
if property_owner_id:
from apps.parks.models import Company
park.property_owner = Company.objects.get(id=property_owner_id)
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
park.full_clean()
park.save()
# Handle location if provided
if location_data:
ParkLocationService.create_park_location(park=park, **location_data)
return park
@staticmethod
def update_park(
*,
park_id: int,
updates: Dict[str, Any],
updated_by: Optional["AbstractUser"] = None,
) -> Park:
"""
Update an existing park with validation.
Args:
park_id: ID of park to update
updates: Dictionary of field updates
updated_by: User performing the update
Returns:
Updated Park instance
Raises:
Park.DoesNotExist: If park doesn't exist
ValidationError: If update data is invalid
"""
with transaction.atomic():
park = Park.objects.select_for_update().get(id=park_id)
# Apply updates
for field, value in updates.items():
if hasattr(park, field):
setattr(park, field, value)
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
park.full_clean()
park.save()
return park
@staticmethod
def delete_park(
*, park_id: int, deleted_by: Optional["AbstractUser"] = None
) -> bool:
"""
Soft delete a park by setting status to DEMOLISHED.
Args:
park_id: ID of park to delete
deleted_by: User performing the deletion
Returns:
True if successfully deleted
Raises:
Park.DoesNotExist: If park doesn't exist
"""
with transaction.atomic():
park = Park.objects.select_for_update().get(id=park_id)
park.status = "DEMOLISHED"
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
park.full_clean()
park.save()
return True
@staticmethod
def create_park_area(
*,
park_id: int,
name: str,
description: str = "",
created_by: Optional["AbstractUser"] = None,
) -> ParkArea:
"""
Create a new area within a park.
Args:
park_id: ID of the parent park
name: Area name
description: Area description
created_by: User creating the area
Returns:
Created ParkArea instance
Raises:
Park.DoesNotExist: If park doesn't exist
ValidationError: If area data is invalid
"""
park = Park.objects.get(id=park_id)
area = ParkArea(park=park, name=name, description=description)
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
area.full_clean()
area.save()
return area
@staticmethod
def update_park_statistics(*, park_id: int) -> Park:
"""
Recalculate and update park statistics (ride counts, ratings).
Args:
park_id: ID of park to update statistics for
Returns:
Updated Park instance with fresh statistics
"""
from apps.rides.models import Ride
from apps.parks.models import ParkReview
from django.db.models import Count, Avg
with transaction.atomic():
park = Park.objects.select_for_update().get(id=park_id)
# Calculate ride counts
ride_stats = Ride.objects.filter(park=park).aggregate(
total_rides=Count("id"),
coaster_count=Count("id", filter=Q(category__in=["RC", "WC"])),
)
# Calculate average rating
avg_rating = ParkReview.objects.filter(
park=park, is_published=True
).aggregate(avg_rating=Avg("rating"))["avg_rating"]
# Update park fields
park.ride_count = ride_stats["total_rides"] or 0
park.coaster_count = ride_stats["coaster_count"] or 0
park.average_rating = avg_rating
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
park.full_clean()
park.save()
return park

View File

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

View File

@@ -1,34 +0,0 @@
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.db.models import Q
from apps.rides.models import Ride
from .models import Park
def update_park_ride_counts(park):
"""Update ride_count and coaster_count for a park"""
operating_rides = Q(status="OPERATING")
# Count total operating rides
ride_count = park.rides.filter(operating_rides).count()
# Count total operating roller coasters
coaster_count = park.rides.filter(operating_rides, category="RC").count()
# Update park counts
Park.objects.filter(id=park.id).update(
ride_count=ride_count, coaster_count=coaster_count
)
@receiver(post_save, sender=Ride)
def ride_saved(sender, instance, **kwargs):
"""Update park counts when a ride is saved"""
update_park_ride_counts(instance.park)
@receiver(post_delete, sender=Ride)
def ride_deleted(sender, instance, **kwargs):
"""Update park counts when a ride is deleted"""
update_park_ride_counts(instance.park)

View File

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

View File

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

View File

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

View File

@@ -1,11 +0,0 @@
from django import template
register = template.Library()
@register.filter
def has_reviewed_park(user, park):
"""Check if a user has reviewed a park"""
if not user.is_authenticated:
return False
return park.reviews.filter(user=user).exists()

View File

@@ -1,117 +0,0 @@
from django.test import TestCase, Client
from django.contrib.auth import get_user_model
from apps.parks.models import Park, ParkArea, ParkLocation, Company as Operator
User = get_user_model()
def create_test_location(park: Park) -> ParkLocation:
"""Helper function to create a test location"""
park_location = ParkLocation.objects.create(
park=park,
street_address="123 Test St",
city="Test City",
state="TS",
country="Test Country",
postal_code="12345",
)
# Set coordinates using the helper method
park_location.set_coordinates(34.0522, -118.2437) # latitude, longitude
park_location.save()
return park_location
class ParkModelTests(TestCase):
@classmethod
def setUpTestData(cls) -> None:
# Create test user
cls.user = User.objects.create_user(
username="testuser",
email="test@example.com",
password="testpass123",
)
# Create test company
cls.operator = Operator.objects.create(
name="Test Company", website="http://example.com"
)
# Create test park
cls.park = Park.objects.create(
name="Test Park",
operator=cls.operator,
status="OPERATING",
website="http://testpark.com",
)
# Create test location
cls.location = create_test_location(cls.park)
def test_park_creation(self) -> None:
"""Test park instance creation and field values"""
self.assertEqual(self.park.name, "Test Park")
self.assertEqual(self.park.operator, self.operator)
self.assertEqual(self.park.status, "OPERATING")
self.assertEqual(self.park.website, "http://testpark.com")
self.assertTrue(self.park.slug)
def test_park_str_representation(self) -> None:
"""Test string representation of park"""
self.assertEqual(str(self.park), "Test Park")
def test_park_coordinates(self) -> None:
"""Test park coordinates property"""
coords = self.park.coordinates
self.assertIsNotNone(coords)
if coords:
self.assertAlmostEqual(coords[0], 34.0522, places=4) # latitude
self.assertAlmostEqual(coords[1], -118.2437, places=4) # longitude
def test_park_formatted_location(self) -> None:
"""Test park formatted_location property"""
expected = "123 Test St, Test City, TS, 12345, Test Country"
self.assertEqual(self.park.formatted_location, expected)
class ParkAreaTests(TestCase):
def setUp(self) -> None:
# Create test company
self.operator = Operator.objects.create(
name="Test Company", website="http://example.com"
)
# Create test park
self.park = Park.objects.create(
name="Test Park", operator=self.operator, status="OPERATING"
)
# Create test location
self.location = create_test_location(self.park)
# Create test area
self.area = ParkArea.objects.create(
park=self.park, name="Test Area", description="Test Description"
)
def test_area_creation(self) -> None:
"""Test park area creation"""
self.assertEqual(self.area.name, "Test Area")
self.assertEqual(self.area.park, self.park)
self.assertTrue(self.area.slug)
class ParkViewTests(TestCase):
def setUp(self) -> None:
self.client = Client()
self.user = User.objects.create_user(
username="testuser",
email="test@example.com",
password="testpass123",
)
self.operator = Operator.objects.create(
name="Test Company", website="http://example.com"
)
self.park = Park.objects.create(
name="Test Park", operator=self.operator, status="OPERATING"
)
self.location = create_test_location(self.park)

View File

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

View File

@@ -1,9 +0,0 @@
from django.apps import AppConfig
class RidesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.rides"
def ready(self):
pass

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +0,0 @@
from django.db.models.signals import pre_save
from django.dispatch import receiver
from django.utils import timezone
from .models import Ride
@receiver(pre_save, sender=Ride)
def handle_ride_status(sender, instance, **kwargs):
"""Handle ride status changes based on closing date"""
if instance.closing_date:
today = timezone.now().date()
# If we've reached the closing date and status is "Closing"
if today >= instance.closing_date and instance.status == "CLOSING":
# Change to the selected post-closing status
instance.status = instance.post_closing_status or "SBNO"
instance.status_since = instance.closing_date

View File

@@ -1 +0,0 @@
# Create your tests here.

48
backend/.env.example Normal file
View File

@@ -0,0 +1,48 @@
# 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
# ForwardEmail API Configuration
FORWARD_EMAIL_BASE_URL=https://api.forwardemail.net
FORWARD_EMAIL_API_KEY=your-forwardemail-api-key-here
FORWARD_EMAIL_DOMAIN=your-domain.com
# Media and Static Files
MEDIA_URL=/media/
STATIC_URL=/static/
# Security
ALLOWED_HOSTS=localhost,127.0.0.1
# API Configuration
CORS_ALLOWED_ORIGINS=http://localhost:3000
# Feature Flags
ENABLE_DEBUG_TOOLBAR=True
ENABLE_SILK_PROFILER=False
# Frontend Configuration
FRONTEND_DOMAIN=https://thrillwiki.com
# Cloudflare Images Configuration
CLOUDFLARE_IMAGES_ACCOUNT_ID=your-cloudflare-account-id
CLOUDFLARE_IMAGES_API_TOKEN=your-cloudflare-api-token
CLOUDFLARE_IMAGES_ACCOUNT_HASH=your-cloudflare-account-hash
CLOUDFLARE_IMAGES_WEBHOOK_SECRET=your-webhook-secret
# Road Trip Service Configuration
ROADTRIP_USER_AGENT=ThrillWiki/1.0 (https://thrillwiki.com)

View File

@@ -0,0 +1,73 @@
# Independent Verification Commands
Run these commands yourself to verify ALL tuple fallbacks have been eliminated:
## 1. Search for the most common tuple fallback patterns:
```bash
# Search for choices.get(value, fallback) patterns
grep -r "choices\.get(" apps/ --include="*.py" | grep -v migration
# Search for status_*.get(value, fallback) patterns
grep -r "status_.*\.get(" apps/ --include="*.py" | grep -v migration
# Search for category_*.get(value, fallback) patterns
grep -r "category_.*\.get(" apps/ --include="*.py" | grep -v migration
# Search for sla_hours.get(value, fallback) patterns
grep -r "sla_hours\.get(" apps/ --include="*.py"
# Search for the removed functions
grep -r "get_tuple_choices\|from_tuple\|convert_tuple_choices" apps/ --include="*.py" | grep -v migration
```
**Expected result: ALL commands should return NOTHING (empty results)**
## 2. Verify the removed function is actually gone:
```bash
# This should fail with ImportError
python -c "from apps.core.choices.registry import get_tuple_choices; print('ERROR: Function still exists!')"
# This should work
python -c "from apps.core.choices.registry import get_choices; print('SUCCESS: Rich Choice objects work')"
```
## 3. Verify Django system integrity:
```bash
python manage.py check
```
**Expected result: Should pass with no errors**
## 4. Manual spot check of previously problematic files:
```bash
# Check rides events (previously had 3 fallbacks)
grep -n "\.get(" apps/rides/events.py | grep -E "(choice|status|category)"
# Check template tags (previously had 2 fallbacks)
grep -n "\.get(" apps/rides/templatetags/ride_tags.py | grep -E "(choice|category|image)"
# Check admin (previously had 2 fallbacks)
grep -n "\.get(" apps/rides/admin.py | grep -E "(choice|category)"
# Check moderation (previously had 3 SLA fallbacks)
grep -n "sla_hours\.get(" apps/moderation/
```
**Expected result: ALL should return NOTHING**
## 5. Run the verification script:
```bash
python verify_no_tuple_fallbacks.py
```
**Expected result: Should print "SUCCESS: ALL TUPLE FALLBACKS HAVE BEEN ELIMINATED!"**
---
If ANY of these commands find tuple fallbacks, then I was wrong.
If ALL commands return empty/success, then ALL tuple fallbacks have been eliminated.

View File

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

View File

@@ -0,0 +1,360 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.utils.html import format_html
from django.contrib.auth.models import Group
from .models import (
User,
UserProfile,
EmailVerification,
PasswordReset,
TopList,
TopListItem,
)
class UserProfileInline(admin.StackedInline):
model = UserProfile
can_delete = False
verbose_name_plural = "Profile"
fieldsets = (
(
"Personal Info",
{"fields": ("display_name", "avatar", "pronouns", "bio")},
),
(
"Social Media",
{"fields": ("twitter", "instagram", "youtube", "discord")},
),
(
"Ride Credits",
{
"fields": (
"coaster_credits",
"dark_ride_credits",
"flat_ride_credits",
"water_ride_credits",
)
},
),
)
class TopListItemInline(admin.TabularInline):
model = TopListItem
extra = 1
fields = ("content_type", "object_id", "rank", "notes")
ordering = ("rank",)
@admin.register(User)
class CustomUserAdmin(UserAdmin):
list_display = (
"username",
"email",
"get_avatar",
"get_status",
"role",
"date_joined",
"last_login",
"get_credits",
)
list_filter = (
"is_active",
"is_staff",
"role",
"is_banned",
"groups",
"date_joined",
)
search_fields = ("username", "email")
ordering = ("-date_joined",)
actions = [
"activate_users",
"deactivate_users",
"ban_users",
"unban_users",
]
inlines = [UserProfileInline]
fieldsets = (
(None, {"fields": ("username", "password")}),
("Personal info", {"fields": ("email", "pending_email")}),
(
"Roles and Permissions",
{
"fields": ("role", "groups", "user_permissions"),
"description": (
"Role determines group membership. Groups determine permissions."
),
},
),
(
"Status",
{
"fields": ("is_active", "is_staff", "is_superuser"),
"description": "These are automatically managed based on role.",
},
),
(
"Ban Status",
{
"fields": ("is_banned", "ban_reason", "ban_date"),
},
),
(
"Preferences",
{
"fields": ("theme_preference",),
},
),
("Important dates", {"fields": ("last_login", "date_joined")}),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": (
"username",
"email",
"password1",
"password2",
"role",
),
},
),
)
@admin.display(description="Avatar")
def get_avatar(self, obj):
if obj.profile.avatar:
return format_html(
'<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;">{}</div>',
obj.username[0].upper(),
)
@admin.display(description="Status")
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):
try:
profile = obj.profile
return format_html(
"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, queryset):
queryset.update(is_active=True)
@admin.action(description="Deactivate selected users")
def deactivate_users(self, request, queryset):
queryset.update(is_active=False)
@admin.action(description="Ban selected users")
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, queryset):
queryset.update(is_banned=False, ban_date=None, ban_reason="")
def save_model(self, request, obj, form, change):
creating = not obj.pk
super().save_model(request, obj, form, change)
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)
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
list_display = (
"user",
"display_name",
"coaster_credits",
"dark_ride_credits",
"flat_ride_credits",
"water_ride_credits",
)
list_filter = (
"coaster_credits",
"dark_ride_credits",
"flat_ride_credits",
"water_ride_credits",
)
search_fields = ("user__username", "user__email", "display_name", "bio")
fieldsets = (
(
"User Information",
{"fields": ("user", "display_name", "avatar", "pronouns", "bio")},
),
(
"Social Media",
{"fields": ("twitter", "instagram", "youtube", "discord")},
),
(
"Ride Credits",
{
"fields": (
"coaster_credits",
"dark_ride_credits",
"flat_ride_credits",
"water_ride_credits",
)
},
),
)
@admin.register(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")
readonly_fields = ("created_at", "last_sent")
fieldsets = (
("Verification Details", {"fields": ("user", "token")}),
("Timing", {"fields": ("created_at", "last_sent")}),
)
@admin.display(description="Status")
def is_expired(self, obj):
from django.utils import timezone
from datetime import timedelta
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):
list_display = ("title", "user", "category", "created_at", "updated_at")
list_filter = ("category", "created_at", "updated_at")
search_fields = ("title", "user__username", "description")
inlines = [TopListItemInline]
fieldsets = (
(
"Basic Information",
{"fields": ("user", "title", "category", "description")},
),
(
"Timestamps",
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
),
)
readonly_fields = ("created_at", "updated_at")
@admin.register(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")
ordering = ("top_list", "rank")
fieldsets = (
("List Information", {"fields": ("top_list", "rank")}),
("Item Details", {"fields": ("content_type", "object_id", "notes")}),
)
@admin.register(PasswordReset)
class PasswordResetAdmin(admin.ModelAdmin):
"""Admin interface for password reset tokens"""
list_display = (
"user",
"created_at",
"expires_at",
"is_expired",
"used",
)
list_filter = (
"used",
"created_at",
"expires_at",
)
search_fields = (
"user__username",
"user__email",
"token",
)
readonly_fields = (
"token",
"created_at",
"expires_at",
)
date_hierarchy = "created_at"
ordering = ("-created_at",)
fieldsets = (
(
"Reset Details",
{
"fields": (
"user",
"token",
"used",
)
},
),
(
"Timing",
{
"fields": (
"created_at",
"expires_at",
)
},
),
)
@admin.display(description="Status", boolean=True)
def is_expired(self, obj):
"""Display expiration status with color coding"""
from django.utils import timezone
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):
"""Disable manual creation of password reset tokens"""
return False
def has_change_permission(self, request, obj=None):
"""Allow viewing but restrict editing of password reset tokens"""
return getattr(request.user, "is_superuser", False)

View File

@@ -0,0 +1,91 @@
"""
Management command to reset the database and create an admin user.
Security Note: This command uses a mix of raw SQL (for PostgreSQL-specific operations
like dropping all tables) and Django ORM (for creating users). The raw SQL operations
use quote_ident() for table/sequence names which is safe from SQL injection.
WARNING: This command is destructive and should only be used in development.
"""
from django.core.management.base import BaseCommand
from django.db import connection
class Command(BaseCommand):
help = "Reset database and create admin user"
def handle(self, *args, **options):
self.stdout.write("Resetting database...")
# Drop all tables using PostgreSQL-specific operations
# Security: Using quote_ident() to safely quote table/sequence names
with connection.cursor() as cursor:
cursor.execute(
"""
DO $$ DECLARE
r RECORD;
BEGIN
FOR r IN (
SELECT tablename FROM pg_tables
WHERE schemaname = current_schema()
) LOOP
EXECUTE 'DROP TABLE IF EXISTS ' ||
quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END $$;
"""
)
# Reset sequences
cursor.execute(
"""
DO $$ DECLARE
r RECORD;
BEGIN
FOR r IN (
SELECT sequencename FROM pg_sequences
WHERE schemaname = current_schema()
) LOOP
EXECUTE 'ALTER SEQUENCE ' ||
quote_ident(r.sequencename) || ' RESTART WITH 1';
END LOOP;
END $$;
"""
)
self.stdout.write("All tables dropped and sequences reset.")
# Run migrations
from django.core.management import call_command
call_command("migrate")
self.stdout.write("Migrations applied.")
# Create superuser using Django ORM (safer than raw SQL)
try:
from apps.accounts.models import User, UserProfile
# Security: Using Django ORM instead of raw SQL for user creation
user = User.objects.create_superuser(
username='admin',
email='admin@thrillwiki.com',
password='admin',
role='SUPERUSER',
)
# Create profile using ORM
UserProfile.objects.create(
user=user,
display_name='Admin',
pronouns='they/them',
bio='ThrillWiki Administrator',
)
self.stdout.write("Superuser created.")
except Exception as e:
self.stdout.write(self.style.ERROR(f"Error creating superuser: {str(e)}"))
raise
self.stdout.write(self.style.SUCCESS("Database reset complete."))

View File

@@ -15,17 +15,17 @@ class Command(BaseCommand):
create_default_groups()
# Sync existing users with groups based on their roles
users = User.objects.exclude(role="USER")
users = User.objects.exclude(role=User.Roles.USER)
for user in users:
group = Group.objects.filter(name=user.role).first()
if group:
user.groups.add(group)
# Update staff/superuser status based on role
if user.role == "SUPERUSER":
if user.role == User.Roles.SUPERUSER:
user.is_superuser = True
user.is_staff = True
elif user.role in ["ADMIN", "MODERATOR"]:
elif user.role in [User.Roles.ADMIN, User.Roles.MODERATOR]:
user.is_staff = True
user.save()

View File

@@ -0,0 +1,551 @@
# Generated by Django 5.1.4 on 2025-08-13 21:35
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
("contenttypes", "0002_remove_content_type_name"),
("pghistory", "0006_delete_aggregateevent"),
]
operations = [
migrations.CreateModel(
name="User",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"password",
models.CharField(max_length=128, verbose_name="password"),
),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True,
max_length=254,
verbose_name="email address",
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now,
verbose_name="date joined",
),
),
(
"user_id",
models.CharField(
editable=False,
help_text="Unique identifier for this user that remains constant even if the username changes",
max_length=10,
unique=True,
),
),
(
"role",
models.CharField(
choices=[
("USER", "User"),
("MODERATOR", "Moderator"),
("ADMIN", "Admin"),
("SUPERUSER", "Superuser"),
],
default="USER",
max_length=10,
),
),
("is_banned", models.BooleanField(default=False)),
("ban_reason", models.TextField(blank=True)),
("ban_date", models.DateTimeField(blank=True, null=True)),
(
"pending_email",
models.EmailField(blank=True, max_length=254, null=True),
),
(
"theme_preference",
models.CharField(
choices=[("light", "Light"), ("dark", "Dark")],
default="light",
max_length=5,
),
),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"abstract": False,
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name="EmailVerification",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("token", models.CharField(max_length=64, unique=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("last_sent", models.DateTimeField(auto_now_add=True)),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Email Verification",
"verbose_name_plural": "Email Verifications",
},
),
migrations.CreateModel(
name="PasswordReset",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("token", models.CharField(max_length=64)),
("created_at", models.DateTimeField(auto_now_add=True)),
("expires_at", models.DateTimeField()),
("used", models.BooleanField(default=False)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Password Reset",
"verbose_name_plural": "Password Resets",
},
),
migrations.CreateModel(
name="TopList",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=100)),
(
"category",
models.CharField(
choices=[
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("PK", "Park"),
],
max_length=2,
),
),
("description", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="top_lists",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-updated_at"],
},
),
migrations.CreateModel(
name="TopListEvent",
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()),
("title", models.CharField(max_length=100)),
(
"category",
models.CharField(
choices=[
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("PK", "Park"),
],
max_length=2,
),
),
("description", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="accounts.toplist",
),
),
(
"user",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="TopListItem",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("object_id", models.PositiveIntegerField()),
("rank", models.PositiveIntegerField()),
("notes", models.TextField(blank=True)),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
(
"top_list",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="items",
to="accounts.toplist",
),
),
],
options={
"ordering": ["rank"],
},
),
migrations.CreateModel(
name="TopListItemEvent",
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()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("object_id", models.PositiveIntegerField()),
("rank", models.PositiveIntegerField()),
("notes", models.TextField(blank=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="accounts.toplistitem",
),
),
(
"top_list",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="accounts.toplist",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="UserProfile",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"profile_id",
models.CharField(
editable=False,
help_text="Unique identifier for this profile that remains constant",
max_length=10,
unique=True,
),
),
(
"display_name",
models.CharField(
help_text="This is the name that will be displayed on the site",
max_length=50,
unique=True,
),
),
(
"avatar",
models.ImageField(blank=True, upload_to="avatars/"),
),
("pronouns", models.CharField(blank=True, max_length=50)),
("bio", models.TextField(blank=True, max_length=500)),
("twitter", models.URLField(blank=True)),
("instagram", models.URLField(blank=True)),
("youtube", models.URLField(blank=True)),
("discord", models.CharField(blank=True, max_length=100)),
("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)),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="profile",
to=settings.AUTH_USER_MODEL,
),
),
],
),
pgtrigger.migrations.AddTrigger(
model_name="toplist",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_toplistevent" ("category", "created_at", "description", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "title", "updated_at", "user_id") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_26546",
table="accounts_toplist",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="toplist",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_toplistevent" ("category", "created_at", "description", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "title", "updated_at", "user_id") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_84849",
table="accounts_toplist",
when="AFTER",
),
),
),
migrations.AlterUniqueTogether(
name="toplistitem",
unique_together={("top_list", "rank")},
),
pgtrigger.migrations.AddTrigger(
model_name="toplistitem",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_toplistitemevent" ("content_type_id", "created_at", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "top_list_id", "updated_at") VALUES (NEW."content_type_id", NEW."created_at", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rank", NEW."top_list_id", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_56dfc",
table="accounts_toplistitem",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="toplistitem",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_toplistitemevent" ("content_type_id", "created_at", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "top_list_id", "updated_at") VALUES (NEW."content_type_id", NEW."created_at", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rank", NEW."top_list_id", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_2b6e3",
table="accounts_toplistitem",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,63 @@
# Generated by Django 5.2.5 on 2025-08-24 18:23
import pgtrigger.migrations
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
migrations.RemoveField(
model_name="toplistevent",
name="pgh_context",
),
migrations.RemoveField(
model_name="toplistevent",
name="pgh_obj",
),
migrations.RemoveField(
model_name="toplistevent",
name="user",
),
migrations.RemoveField(
model_name="toplistitemevent",
name="content_type",
),
migrations.RemoveField(
model_name="toplistitemevent",
name="pgh_context",
),
migrations.RemoveField(
model_name="toplistitemevent",
name="pgh_obj",
),
migrations.RemoveField(
model_name="toplistitemevent",
name="top_list",
),
pgtrigger.migrations.RemoveTrigger(
model_name="toplist",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="toplist",
name="update_update",
),
pgtrigger.migrations.RemoveTrigger(
model_name="toplistitem",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="toplistitem",
name="update_update",
),
migrations.DeleteModel(
name="TopListEvent",
),
migrations.DeleteModel(
name="TopListItemEvent",
),
]

View File

@@ -0,0 +1,438 @@
# Generated by Django 5.2.5 on 2025-08-24 19:11
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0002_remove_toplistevent_pgh_context_and_more"),
("pghistory", "0006_delete_aggregateevent"),
]
operations = [
migrations.CreateModel(
name="EmailVerificationEvent",
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()),
("token", models.CharField(max_length=64)),
("created_at", models.DateTimeField(auto_now_add=True)),
("last_sent", models.DateTimeField(auto_now_add=True)),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="PasswordResetEvent",
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()),
("token", models.CharField(max_length=64)),
("created_at", models.DateTimeField(auto_now_add=True)),
("expires_at", models.DateTimeField()),
("used", models.BooleanField(default=False)),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="UserEvent",
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()),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"user_id",
models.CharField(
editable=False,
help_text="Unique identifier for this user that remains constant even if the username changes",
max_length=10,
),
),
(
"role",
models.CharField(
choices=[
("USER", "User"),
("MODERATOR", "Moderator"),
("ADMIN", "Admin"),
("SUPERUSER", "Superuser"),
],
default="USER",
max_length=10,
),
),
("is_banned", models.BooleanField(default=False)),
("ban_reason", models.TextField(blank=True)),
("ban_date", models.DateTimeField(blank=True, null=True)),
(
"pending_email",
models.EmailField(blank=True, max_length=254, null=True),
),
(
"theme_preference",
models.CharField(
choices=[("light", "Light"), ("dark", "Dark")],
default="light",
max_length=5,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="UserProfileEvent",
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()),
(
"profile_id",
models.CharField(
editable=False,
help_text="Unique identifier for this profile that remains constant",
max_length=10,
),
),
(
"display_name",
models.CharField(
help_text="This is the name that will be displayed on the site",
max_length=50,
),
),
("avatar", models.ImageField(blank=True, upload_to="avatars/")),
("pronouns", models.CharField(blank=True, max_length=50)),
("bio", models.TextField(blank=True, max_length=500)),
("twitter", models.URLField(blank=True)),
("instagram", models.URLField(blank=True)),
("youtube", models.URLField(blank=True)),
("discord", models.CharField(blank=True, max_length=100)),
("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)),
],
options={
"abstract": False,
},
),
pgtrigger.migrations.AddTrigger(
model_name="emailverification",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_emailverificationevent" ("created_at", "id", "last_sent", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "token", "user_id") VALUES (NEW."created_at", NEW."id", NEW."last_sent", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."token", NEW."user_id"); RETURN NULL;',
hash="c485bf0cd5bea8a05ef2d4ae309b60eff42abd84",
operation="INSERT",
pgid="pgtrigger_insert_insert_53748",
table="accounts_emailverification",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="emailverification",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_emailverificationevent" ("created_at", "id", "last_sent", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "token", "user_id") VALUES (NEW."created_at", NEW."id", NEW."last_sent", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."token", NEW."user_id"); RETURN NULL;',
hash="c20942bdc0713db74310da8da8c3138ca4c3bba9",
operation="UPDATE",
pgid="pgtrigger_update_update_7a2a8",
table="accounts_emailverification",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="passwordreset",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_passwordresetevent" ("created_at", "expires_at", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "token", "used", "user_id") VALUES (NEW."created_at", NEW."expires_at", NEW."id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."token", NEW."used", NEW."user_id"); RETURN NULL;',
hash="496ac059671b25460cdf2ca20d0e43b14d417a26",
operation="INSERT",
pgid="pgtrigger_insert_insert_d2b72",
table="accounts_passwordreset",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="passwordreset",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_passwordresetevent" ("created_at", "expires_at", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "token", "used", "user_id") VALUES (NEW."created_at", NEW."expires_at", NEW."id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."token", NEW."used", NEW."user_id"); RETURN NULL;',
hash="c40acc416f85287b4a6fcc06724626707df90016",
operation="UPDATE",
pgid="pgtrigger_update_update_526d2",
table="accounts_passwordreset",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="user",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_userevent" ("ban_date", "ban_reason", "date_joined", "email", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "role", "theme_preference", "user_id", "username") VALUES (NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."email", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."role", NEW."theme_preference", NEW."user_id", NEW."username"); RETURN NULL;',
hash="b6992f02a4c1135fef9527e3f1ed330e2e626267",
operation="INSERT",
pgid="pgtrigger_insert_insert_3867c",
table="accounts_user",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="user",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_userevent" ("ban_date", "ban_reason", "date_joined", "email", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "role", "theme_preference", "user_id", "username") VALUES (NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."email", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."role", NEW."theme_preference", NEW."user_id", NEW."username"); RETURN NULL;',
hash="6c3271b9f184dc137da7b9e42b0ae9f72d47c9c2",
operation="UPDATE",
pgid="pgtrigger_update_update_0e890",
table="accounts_user",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="userprofile",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_userprofileevent" ("avatar", "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", 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="af6a89f13ff879d978a1154bbcf4664de0fcf913",
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", "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", 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="37e99b5cc374ec0a3fc44d2482b411cba63fa84d",
operation="UPDATE",
pgid="pgtrigger_update_update_87ef6",
table="accounts_userprofile",
when="AFTER",
),
),
),
migrations.AddField(
model_name="emailverificationevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="emailverificationevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="accounts.emailverification",
),
),
migrations.AddField(
model_name="emailverificationevent",
name="user",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="passwordresetevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="passwordresetevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="accounts.passwordreset",
),
),
migrations.AddField(
model_name="passwordresetevent",
name="user",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="userevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="userevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="userprofileevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="userprofileevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="accounts.userprofile",
),
),
migrations.AddField(
model_name="userprofileevent",
name="user",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@@ -0,0 +1,219 @@
# Generated by Django 5.2.5 on 2025-08-29 14:55
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"accounts",
"0003_emailverificationevent_passwordresetevent_userevent_and_more",
),
("pghistory", "0006_delete_aggregateevent"),
]
operations = [
migrations.CreateModel(
name="UserDeletionRequest",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"verification_code",
models.CharField(
help_text="Unique verification code sent to user's email",
max_length=32,
unique=True,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"expires_at",
models.DateTimeField(
help_text="When this deletion request expires"
),
),
(
"email_sent_at",
models.DateTimeField(
blank=True,
help_text="When the verification email was sent",
null=True,
),
),
(
"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",
),
),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="deletion_request",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-created_at"],
},
),
migrations.CreateModel(
name="UserDeletionRequestEvent",
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()),
(
"verification_code",
models.CharField(
help_text="Unique verification code sent to user's email",
max_length=32,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"expires_at",
models.DateTimeField(
help_text="When this deletion request expires"
),
),
(
"email_sent_at",
models.DateTimeField(
blank=True,
help_text="When the verification email was sent",
null=True,
),
),
(
"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",
),
),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="accounts.userdeletionrequest",
),
),
(
"user",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.AddIndex(
model_name="userdeletionrequest",
index=models.Index(
fields=["verification_code"], name="accounts_us_verific_94460d_idx"
),
),
migrations.AddIndex(
model_name="userdeletionrequest",
index=models.Index(
fields=["expires_at"], name="accounts_us_expires_1d1dca_idx"
),
),
migrations.AddIndex(
model_name="userdeletionrequest",
index=models.Index(
fields=["user", "is_used"], name="accounts_us_user_id_1ce18a_idx"
),
),
pgtrigger.migrations.AddTrigger(
model_name="userdeletionrequest",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_userdeletionrequestevent" ("attempts", "created_at", "email_sent_at", "expires_at", "id", "is_used", "max_attempts", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "user_id", "verification_code") VALUES (NEW."attempts", NEW."created_at", NEW."email_sent_at", NEW."expires_at", NEW."id", NEW."is_used", NEW."max_attempts", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."user_id", NEW."verification_code"); RETURN NULL;',
hash="c1735fe8eb50247b0afe2bea9d32f83c31da6419",
operation="INSERT",
pgid="pgtrigger_insert_insert_b982c",
table="accounts_userdeletionrequest",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="userdeletionrequest",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_userdeletionrequestevent" ("attempts", "created_at", "email_sent_at", "expires_at", "id", "is_used", "max_attempts", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "user_id", "verification_code") VALUES (NEW."attempts", NEW."created_at", NEW."email_sent_at", NEW."expires_at", NEW."id", NEW."is_used", NEW."max_attempts", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."user_id", NEW."verification_code"); RETURN NULL;',
hash="6bf807ce3bed069ab30462d3fd7688a7593a7fd0",
operation="UPDATE",
pgid="pgtrigger_update_update_27723",
table="accounts_userdeletionrequest",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,309 @@
# Generated by Django 5.2.5 on 2025-08-29 15:10
import django.utils.timezone
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0004_userdeletionrequest_userdeletionrequestevent_and_more"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="user",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="user",
name="update_update",
),
migrations.AddField(
model_name="user",
name="activity_visibility",
field=models.CharField(
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="friends",
max_length=10,
),
),
migrations.AddField(
model_name="user",
name="allow_friend_requests",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="allow_messages",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="allow_profile_comments",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="user",
name="email_notifications",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="last_password_change",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="user",
name="login_history_retention",
field=models.IntegerField(default=90),
),
migrations.AddField(
model_name="user",
name="login_notifications",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="notification_preferences",
field=models.JSONField(
blank=True,
default=dict,
help_text="Detailed notification preferences stored as JSON",
),
),
migrations.AddField(
model_name="user",
name="privacy_level",
field=models.CharField(
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="public",
max_length=10,
),
),
migrations.AddField(
model_name="user",
name="push_notifications",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="user",
name="search_visibility",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="session_timeout",
field=models.IntegerField(default=30),
),
migrations.AddField(
model_name="user",
name="show_email",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="user",
name="show_join_date",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="show_photos",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="show_real_name",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="show_reviews",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="show_statistics",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="show_top_lists",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="two_factor_enabled",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="userevent",
name="activity_visibility",
field=models.CharField(
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="friends",
max_length=10,
),
),
migrations.AddField(
model_name="userevent",
name="allow_friend_requests",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userevent",
name="allow_messages",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userevent",
name="allow_profile_comments",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="userevent",
name="email_notifications",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userevent",
name="last_password_change",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="userevent",
name="login_history_retention",
field=models.IntegerField(default=90),
),
migrations.AddField(
model_name="userevent",
name="login_notifications",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userevent",
name="notification_preferences",
field=models.JSONField(
blank=True,
default=dict,
help_text="Detailed notification preferences stored as JSON",
),
),
migrations.AddField(
model_name="userevent",
name="privacy_level",
field=models.CharField(
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="public",
max_length=10,
),
),
migrations.AddField(
model_name="userevent",
name="push_notifications",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="userevent",
name="search_visibility",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userevent",
name="session_timeout",
field=models.IntegerField(default=30),
),
migrations.AddField(
model_name="userevent",
name="show_email",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="userevent",
name="show_join_date",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userevent",
name="show_photos",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userevent",
name="show_real_name",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userevent",
name="show_reviews",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userevent",
name="show_statistics",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userevent",
name="show_top_lists",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userevent",
name="two_factor_enabled",
field=models.BooleanField(default=False),
),
pgtrigger.migrations.AddTrigger(
model_name="user",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "email", "email_notifications", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."email", NEW."email_notifications", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
hash="63ede44a0db376d673078f3464edc89aa8ca80c7",
operation="INSERT",
pgid="pgtrigger_insert_insert_3867c",
table="accounts_user",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="user",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "email", "email_notifications", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."email", NEW."email_notifications", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
hash="9157131b568edafe1e5fcdf313bfeaaa8adcfee4",
operation="UPDATE",
pgid="pgtrigger_update_update_0e890",
table="accounts_user",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,88 @@
# Generated by Django 5.2.5 on 2025-08-29 19:09
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0005_remove_user_insert_insert_remove_user_update_update_and_more"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="user",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="user",
name="update_update",
),
migrations.AddField(
model_name="user",
name="display_name",
field=models.CharField(
blank=True,
help_text="Display name shown throughout the site. Falls back to username if not set.",
max_length=50,
),
),
migrations.AddField(
model_name="userevent",
name="display_name",
field=models.CharField(
blank=True,
help_text="Display name shown throughout the site. Falls back to username if not set.",
max_length=50,
),
),
migrations.AlterField(
model_name="userprofile",
name="display_name",
field=models.CharField(
blank=True,
help_text="Legacy display name field - use User.display_name instead",
max_length=50,
),
),
migrations.AlterField(
model_name="userprofileevent",
name="display_name",
field=models.CharField(
blank=True,
help_text="Legacy display name field - use User.display_name instead",
max_length=50,
),
),
pgtrigger.migrations.AddTrigger(
model_name="user",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "display_name", "email", "email_notifications", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."display_name", NEW."email", NEW."email_notifications", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
hash="97e02685f062c04c022f6975784dce80396d4371",
operation="INSERT",
pgid="pgtrigger_insert_insert_3867c",
table="accounts_user",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="user",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "display_name", "email", "email_notifications", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."display_name", NEW."email", NEW."email_notifications", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
hash="e074b317983a921b440b0c8754ba04a31ea513dd",
operation="UPDATE",
pgid="pgtrigger_update_update_0e890",
table="accounts_user",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,68 @@
# Generated by Django 5.2.5 on 2025-08-29 21:32
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("accounts", "0007_add_display_name_to_user"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="user",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="user",
name="update_update",
),
migrations.RemoveField(
model_name="user",
name="first_name",
),
migrations.RemoveField(
model_name="user",
name="last_name",
),
migrations.RemoveField(
model_name="userevent",
name="first_name",
),
migrations.RemoveField(
model_name="userevent",
name="last_name",
),
pgtrigger.migrations.AddTrigger(
model_name="user",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "display_name", "email", "email_notifications", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."display_name", NEW."email", NEW."email_notifications", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
hash="1ffd9209b0e1949c05de2548585cda9179288b68",
operation="INSERT",
pgid="pgtrigger_insert_insert_3867c",
table="accounts_user",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="user",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "display_name", "email", "email_notifications", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."display_name", NEW."email", NEW."email_notifications", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
hash="e5f0a1acc20a9aad226004bc93ca8dbc3511052f",
operation="UPDATE",
pgid="pgtrigger_update_update_0e890",
table="accounts_user",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,509 @@
# Generated by Django 5.2.5 on 2025-08-30 20:55
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0008_remove_first_last_name_fields"),
("contenttypes", "0002_remove_content_type_name"),
("django_cloudflareimages_toolkit", "0001_initial"),
("pghistory", "0006_delete_aggregateevent"),
]
operations = [
migrations.CreateModel(
name="NotificationPreference",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("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_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)),
("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_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_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)),
],
options={
"verbose_name": "Notification Preference",
"verbose_name_plural": "Notification Preferences",
"abstract": False,
},
),
migrations.CreateModel(
name="NotificationPreferenceEvent",
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()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("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_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)),
("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_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_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)),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="UserNotification",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("updated_at", models.DateTimeField(auto_now=True)),
(
"notification_type",
models.CharField(
choices=[
("submission_approved", "Submission Approved"),
("submission_rejected", "Submission Rejected"),
("submission_pending", "Submission Pending Review"),
("review_reply", "Review Reply"),
("review_helpful", "Review Marked Helpful"),
("friend_request", "Friend Request"),
("friend_accepted", "Friend Request Accepted"),
("message_received", "Message Received"),
("profile_comment", "Profile Comment"),
("system_announcement", "System Announcement"),
("account_security", "Account Security"),
("feature_update", "Feature Update"),
("maintenance", "Maintenance Notice"),
("achievement_unlocked", "Achievement Unlocked"),
("milestone_reached", "Milestone Reached"),
],
max_length=30,
),
),
("title", models.CharField(max_length=200)),
("message", models.TextField()),
("object_id", models.PositiveIntegerField(blank=True, null=True)),
(
"priority",
models.CharField(
choices=[
("low", "Low"),
("normal", "Normal"),
("high", "High"),
("urgent", "Urgent"),
],
default="normal",
max_length=10,
),
),
("is_read", models.BooleanField(default=False)),
("read_at", models.DateTimeField(blank=True, null=True)),
("email_sent", models.BooleanField(default=False)),
("email_sent_at", models.DateTimeField(blank=True, null=True)),
("push_sent", models.BooleanField(default=False)),
("push_sent_at", models.DateTimeField(blank=True, null=True)),
("extra_data", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
("expires_at", models.DateTimeField(blank=True, null=True)),
],
options={
"ordering": ["-created_at"],
"abstract": False,
},
),
migrations.CreateModel(
name="UserNotificationEvent",
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()),
("updated_at", models.DateTimeField(auto_now=True)),
(
"notification_type",
models.CharField(
choices=[
("submission_approved", "Submission Approved"),
("submission_rejected", "Submission Rejected"),
("submission_pending", "Submission Pending Review"),
("review_reply", "Review Reply"),
("review_helpful", "Review Marked Helpful"),
("friend_request", "Friend Request"),
("friend_accepted", "Friend Request Accepted"),
("message_received", "Message Received"),
("profile_comment", "Profile Comment"),
("system_announcement", "System Announcement"),
("account_security", "Account Security"),
("feature_update", "Feature Update"),
("maintenance", "Maintenance Notice"),
("achievement_unlocked", "Achievement Unlocked"),
("milestone_reached", "Milestone Reached"),
],
max_length=30,
),
),
("title", models.CharField(max_length=200)),
("message", models.TextField()),
("object_id", models.PositiveIntegerField(blank=True, null=True)),
(
"priority",
models.CharField(
choices=[
("low", "Low"),
("normal", "Normal"),
("high", "High"),
("urgent", "Urgent"),
],
default="normal",
max_length=10,
),
),
("is_read", models.BooleanField(default=False)),
("read_at", models.DateTimeField(blank=True, null=True)),
("email_sent", models.BooleanField(default=False)),
("email_sent_at", models.DateTimeField(blank=True, null=True)),
("push_sent", models.BooleanField(default=False)),
("push_sent_at", models.DateTimeField(blank=True, null=True)),
("extra_data", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
("expires_at", models.DateTimeField(blank=True, null=True)),
],
options={
"abstract": False,
},
),
pgtrigger.migrations.RemoveTrigger(
model_name="userprofile",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="userprofile",
name="update_update",
),
migrations.AlterField(
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.AlterField(
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",
),
),
),
migrations.AddField(
model_name="notificationpreference",
name="user",
field=models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="notification_preference",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="notificationpreferenceevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="notificationpreferenceevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="accounts.notificationpreference",
),
),
migrations.AddField(
model_name="notificationpreferenceevent",
name="user",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="usernotification",
name="content_type",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
migrations.AddField(
model_name="usernotification",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notifications",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="usernotificationevent",
name="content_type",
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
migrations.AddField(
model_name="usernotificationevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="usernotificationevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="accounts.usernotification",
),
),
migrations.AddField(
model_name="usernotificationevent",
name="user",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
pgtrigger.migrations.AddTrigger(
model_name="notificationpreference",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_notificationpreferenceevent" ("account_security_email", "account_security_inapp", "account_security_push", "achievement_unlocked_email", "achievement_unlocked_inapp", "achievement_unlocked_push", "created_at", "feature_update_email", "feature_update_inapp", "feature_update_push", "friend_accepted_email", "friend_accepted_inapp", "friend_accepted_push", "friend_request_email", "friend_request_inapp", "friend_request_push", "id", "message_received_email", "message_received_inapp", "message_received_push", "milestone_reached_email", "milestone_reached_inapp", "milestone_reached_push", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "review_helpful_email", "review_helpful_inapp", "review_helpful_push", "review_reply_email", "review_reply_inapp", "review_reply_push", "submission_approved_email", "submission_approved_inapp", "submission_approved_push", "submission_pending_email", "submission_pending_inapp", "submission_pending_push", "submission_rejected_email", "submission_rejected_inapp", "submission_rejected_push", "system_announcement_email", "system_announcement_inapp", "system_announcement_push", "updated_at", "user_id") VALUES (NEW."account_security_email", NEW."account_security_inapp", NEW."account_security_push", NEW."achievement_unlocked_email", NEW."achievement_unlocked_inapp", NEW."achievement_unlocked_push", NEW."created_at", NEW."feature_update_email", NEW."feature_update_inapp", NEW."feature_update_push", NEW."friend_accepted_email", NEW."friend_accepted_inapp", NEW."friend_accepted_push", NEW."friend_request_email", NEW."friend_request_inapp", NEW."friend_request_push", NEW."id", NEW."message_received_email", NEW."message_received_inapp", NEW."message_received_push", NEW."milestone_reached_email", NEW."milestone_reached_inapp", NEW."milestone_reached_push", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."review_helpful_email", NEW."review_helpful_inapp", NEW."review_helpful_push", NEW."review_reply_email", NEW."review_reply_inapp", NEW."review_reply_push", NEW."submission_approved_email", NEW."submission_approved_inapp", NEW."submission_approved_push", NEW."submission_pending_email", NEW."submission_pending_inapp", NEW."submission_pending_push", NEW."submission_rejected_email", NEW."submission_rejected_inapp", NEW."submission_rejected_push", NEW."system_announcement_email", NEW."system_announcement_inapp", NEW."system_announcement_push", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="bbaa03794722dab95c97ed93731d8b55f314dbdc",
operation="INSERT",
pgid="pgtrigger_insert_insert_4a06b",
table="accounts_notificationpreference",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="notificationpreference",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_notificationpreferenceevent" ("account_security_email", "account_security_inapp", "account_security_push", "achievement_unlocked_email", "achievement_unlocked_inapp", "achievement_unlocked_push", "created_at", "feature_update_email", "feature_update_inapp", "feature_update_push", "friend_accepted_email", "friend_accepted_inapp", "friend_accepted_push", "friend_request_email", "friend_request_inapp", "friend_request_push", "id", "message_received_email", "message_received_inapp", "message_received_push", "milestone_reached_email", "milestone_reached_inapp", "milestone_reached_push", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "review_helpful_email", "review_helpful_inapp", "review_helpful_push", "review_reply_email", "review_reply_inapp", "review_reply_push", "submission_approved_email", "submission_approved_inapp", "submission_approved_push", "submission_pending_email", "submission_pending_inapp", "submission_pending_push", "submission_rejected_email", "submission_rejected_inapp", "submission_rejected_push", "system_announcement_email", "system_announcement_inapp", "system_announcement_push", "updated_at", "user_id") VALUES (NEW."account_security_email", NEW."account_security_inapp", NEW."account_security_push", NEW."achievement_unlocked_email", NEW."achievement_unlocked_inapp", NEW."achievement_unlocked_push", NEW."created_at", NEW."feature_update_email", NEW."feature_update_inapp", NEW."feature_update_push", NEW."friend_accepted_email", NEW."friend_accepted_inapp", NEW."friend_accepted_push", NEW."friend_request_email", NEW."friend_request_inapp", NEW."friend_request_push", NEW."id", NEW."message_received_email", NEW."message_received_inapp", NEW."message_received_push", NEW."milestone_reached_email", NEW."milestone_reached_inapp", NEW."milestone_reached_push", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."review_helpful_email", NEW."review_helpful_inapp", NEW."review_helpful_push", NEW."review_reply_email", NEW."review_reply_inapp", NEW."review_reply_push", NEW."submission_approved_email", NEW."submission_approved_inapp", NEW."submission_approved_push", NEW."submission_pending_email", NEW."submission_pending_inapp", NEW."submission_pending_push", NEW."submission_rejected_email", NEW."submission_rejected_inapp", NEW."submission_rejected_push", NEW."system_announcement_email", NEW."system_announcement_inapp", NEW."system_announcement_push", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="0de72b66f87f795aaeb49be8e4e57d632781bd3a",
operation="UPDATE",
pgid="pgtrigger_update_update_d3fc0",
table="accounts_notificationpreference",
when="AFTER",
),
),
),
migrations.AddIndex(
model_name="usernotification",
index=models.Index(
fields=["user", "is_read"], name="accounts_us_user_id_785929_idx"
),
),
migrations.AddIndex(
model_name="usernotification",
index=models.Index(
fields=["user", "notification_type"],
name="accounts_us_user_id_8cea97_idx",
),
),
migrations.AddIndex(
model_name="usernotification",
index=models.Index(
fields=["created_at"], name="accounts_us_created_a62f54_idx"
),
),
migrations.AddIndex(
model_name="usernotification",
index=models.Index(
fields=["expires_at"], name="accounts_us_expires_f267b1_idx"
),
),
pgtrigger.migrations.AddTrigger(
model_name="usernotification",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_usernotificationevent" ("content_type_id", "created_at", "email_sent", "email_sent_at", "expires_at", "extra_data", "id", "is_read", "message", "notification_type", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "priority", "push_sent", "push_sent_at", "read_at", "title", "updated_at", "user_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."email_sent", NEW."email_sent_at", NEW."expires_at", NEW."extra_data", NEW."id", NEW."is_read", NEW."message", NEW."notification_type", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."priority", NEW."push_sent", NEW."push_sent_at", NEW."read_at", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="822a189e675a5903841d19738c29aa94267417f1",
operation="INSERT",
pgid="pgtrigger_insert_insert_2794b",
table="accounts_usernotification",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="usernotification",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_usernotificationevent" ("content_type_id", "created_at", "email_sent", "email_sent_at", "expires_at", "extra_data", "id", "is_read", "message", "notification_type", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "priority", "push_sent", "push_sent_at", "read_at", "title", "updated_at", "user_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."email_sent", NEW."email_sent_at", NEW."expires_at", NEW."extra_data", NEW."id", NEW."is_read", NEW."message", NEW."notification_type", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."priority", NEW."push_sent", NEW."push_sent_at", NEW."read_at", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="1fd24a77684747bd9a521447a2978529085b6c07",
operation="UPDATE",
pgid="pgtrigger_update_update_15c54",
table="accounts_usernotification",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,106 @@
# Generated by Django 5.2.5 on 2025-08-30 20:57
from django.db import migrations, models
def migrate_avatar_data(apps, schema_editor):
"""
Migrate avatar data from old CloudflareImageField to new ForeignKey structure.
Since we're transitioning to a new system, we'll just drop the old avatar column
and add the new avatar_id column for ForeignKey relationships.
"""
# This is a data migration - we'll handle the schema changes in the operations
pass
def reverse_migrate_avatar_data(apps, schema_editor):
"""
Reverse migration - not implemented as this is a one-way migration
"""
pass
def safe_add_avatar_field(apps, schema_editor):
"""
Safely add avatar field, checking if it already exists.
"""
# Check if the column already exists
with schema_editor.connection.cursor() as cursor:
cursor.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name='accounts_userprofile'
AND column_name='avatar_id'
""")
column_exists = cursor.fetchone() is not None
if not column_exists:
# Column doesn't exist, add it
UserProfile = apps.get_model('accounts', 'UserProfile')
field = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
on_delete=models.SET_NULL,
null=True,
blank=True
)
field.set_attributes_from_name('avatar')
schema_editor.add_field(UserProfile, field)
def reverse_safe_add_avatar_field(apps, schema_editor):
"""
Reverse the safe avatar field addition.
"""
# Check if the column exists and remove it
with schema_editor.connection.cursor() as cursor:
cursor.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name='accounts_userprofile'
AND column_name='avatar_id'
""")
column_exists = cursor.fetchone() is not None
if column_exists:
UserProfile = apps.get_model('accounts', 'UserProfile')
field = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
on_delete=models.SET_NULL,
null=True,
blank=True
)
field.set_attributes_from_name('avatar')
schema_editor.remove_field(UserProfile, field)
class Migration(migrations.Migration):
dependencies = [
(
"accounts",
"0009_notificationpreference_notificationpreferenceevent_and_more",
),
("django_cloudflareimages_toolkit", "0001_initial"),
]
operations = [
# First, remove the old avatar column (CloudflareImageField)
migrations.RunSQL(
"ALTER TABLE accounts_userprofile DROP COLUMN IF EXISTS avatar;",
reverse_sql="-- Cannot reverse this operation"
),
# Safely add the new avatar_id column for ForeignKey
migrations.RunPython(
safe_add_avatar_field,
reverse_safe_add_avatar_field,
),
# Run the data migration
migrations.RunPython(
migrate_avatar_data,
reverse_migrate_avatar_data,
),
]

View File

@@ -0,0 +1,37 @@
# Generated manually on 2025-08-30 to fix pghistory event table schema
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('accounts', '0010_auto_20250830_1657'),
('django_cloudflareimages_toolkit', '0001_initial'),
]
operations = [
# Remove the old avatar field from the event table
migrations.RunSQL(
"ALTER TABLE accounts_userprofileevent DROP COLUMN IF EXISTS avatar;",
reverse_sql="-- Cannot reverse this operation"
),
# Add the new avatar_id field to match the main table (only if it doesn't exist)
migrations.RunSQL(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT column_name
FROM information_schema.columns
WHERE table_name='accounts_userprofileevent'
AND column_name='avatar_id'
) THEN
ALTER TABLE accounts_userprofileevent ADD COLUMN avatar_id uuid;
END IF;
END $$;
""",
reverse_sql="ALTER TABLE accounts_userprofileevent DROP COLUMN IF EXISTS avatar_id;"
),
]

View File

@@ -0,0 +1,241 @@
# Generated by Django 5.2.5 on 2025-09-15 17:35
import apps.core.choices.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("accounts", "0011_fix_userprofile_event_avatar_field"),
]
operations = [
migrations.AlterField(
model_name="toplist",
name="category",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="top_list_categories",
choices=[
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("PK", "Park"),
],
domain="accounts",
max_length=2,
),
),
migrations.AlterField(
model_name="user",
name="activity_visibility",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="privacy_levels",
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="friends",
domain="accounts",
max_length=10,
),
),
migrations.AlterField(
model_name="user",
name="privacy_level",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="privacy_levels",
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="public",
domain="accounts",
max_length=10,
),
),
migrations.AlterField(
model_name="user",
name="role",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="user_roles",
choices=[
("USER", "User"),
("MODERATOR", "Moderator"),
("ADMIN", "Admin"),
("SUPERUSER", "Superuser"),
],
default="USER",
domain="accounts",
max_length=10,
),
),
migrations.AlterField(
model_name="user",
name="theme_preference",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="theme_preferences",
choices=[("light", "Light"), ("dark", "Dark")],
default="light",
domain="accounts",
max_length=5,
),
),
migrations.AlterField(
model_name="userevent",
name="activity_visibility",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="privacy_levels",
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="friends",
domain="accounts",
max_length=10,
),
),
migrations.AlterField(
model_name="userevent",
name="privacy_level",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="privacy_levels",
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="public",
domain="accounts",
max_length=10,
),
),
migrations.AlterField(
model_name="userevent",
name="role",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="user_roles",
choices=[
("USER", "User"),
("MODERATOR", "Moderator"),
("ADMIN", "Admin"),
("SUPERUSER", "Superuser"),
],
default="USER",
domain="accounts",
max_length=10,
),
),
migrations.AlterField(
model_name="userevent",
name="theme_preference",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="theme_preferences",
choices=[("light", "Light"), ("dark", "Dark")],
default="light",
domain="accounts",
max_length=5,
),
),
migrations.AlterField(
model_name="usernotification",
name="notification_type",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="notification_types",
choices=[
("submission_approved", "Submission Approved"),
("submission_rejected", "Submission Rejected"),
("submission_pending", "Submission Pending Review"),
("review_reply", "Review Reply"),
("review_helpful", "Review Marked Helpful"),
("friend_request", "Friend Request"),
("friend_accepted", "Friend Request Accepted"),
("message_received", "Message Received"),
("profile_comment", "Profile Comment"),
("system_announcement", "System Announcement"),
("account_security", "Account Security"),
("feature_update", "Feature Update"),
("maintenance", "Maintenance Notice"),
("achievement_unlocked", "Achievement Unlocked"),
("milestone_reached", "Milestone Reached"),
],
domain="accounts",
max_length=30,
),
),
migrations.AlterField(
model_name="usernotification",
name="priority",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="notification_priorities",
choices=[
("low", "Low"),
("normal", "Normal"),
("high", "High"),
("urgent", "Urgent"),
],
default="normal",
domain="accounts",
max_length=10,
),
),
migrations.AlterField(
model_name="usernotificationevent",
name="notification_type",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="notification_types",
choices=[
("submission_approved", "Submission Approved"),
("submission_rejected", "Submission Rejected"),
("submission_pending", "Submission Pending Review"),
("review_reply", "Review Reply"),
("review_helpful", "Review Marked Helpful"),
("friend_request", "Friend Request"),
("friend_accepted", "Friend Request Accepted"),
("message_received", "Message Received"),
("profile_comment", "Profile Comment"),
("system_announcement", "System Announcement"),
("account_security", "Account Security"),
("feature_update", "Feature Update"),
("maintenance", "Maintenance Notice"),
("achievement_unlocked", "Achievement Unlocked"),
("milestone_reached", "Milestone Reached"),
],
domain="accounts",
max_length=30,
),
),
migrations.AlterField(
model_name="usernotificationevent",
name="priority",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="notification_priorities",
choices=[
("low", "Low"),
("normal", "Normal"),
("high", "High"),
("urgent", "Urgent"),
],
default="normal",
domain="accounts",
max_length=10,
),
),
]

View File

@@ -0,0 +1,40 @@
"""
Add performance indexes and constraints to User model.
This migration adds:
1. db_index=True to is_banned and role fields for faster filtering
2. Composite index on (is_banned, role) for common query patterns
3. CheckConstraint to ensure banned users have a ban_date set
"""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0012_alter_toplist_category_and_more'),
]
operations = [
# Add db_index to is_banned field
migrations.AlterField(
model_name='user',
name='is_banned',
field=models.BooleanField(default=False, db_index=True),
),
# Add composite index for common query patterns
migrations.AddIndex(
model_name='user',
index=models.Index(fields=['is_banned', 'role'], name='accounts_user_banned_role_idx'),
),
# Add CheckConstraint for ban consistency
migrations.AddConstraint(
model_name='user',
constraint=models.CheckConstraint(
name='user_ban_consistency',
check=models.Q(is_banned=False) | models.Q(ban_date__isnull=False),
violation_error_message='Banned users must have a ban_date set'
),
),
]

View File

@@ -49,8 +49,9 @@ class User(AbstractUser):
domain="accounts",
max_length=10,
default="USER",
db_index=True,
)
is_banned = models.BooleanField(default=False)
is_banned = models.BooleanField(default=False, db_index=True)
ban_reason = models.TextField(blank=True)
ban_date = models.DateTimeField(null=True, blank=True)
pending_email = models.EmailField(blank=True, null=True)
@@ -121,8 +122,24 @@ class User(AbstractUser):
"""Get the user's display name, falling back to username if not set"""
if self.display_name:
return self.display_name
# Fallback to profile display_name for backward compatibility
profile = getattr(self, "profile", None)
if profile and profile.display_name:
return profile.display_name
return self.username
class Meta:
indexes = [
models.Index(fields=['is_banned', 'role'], name='accounts_user_banned_role_idx'),
]
constraints = [
models.CheckConstraint(
name='user_ban_consistency',
check=models.Q(is_banned=False) | models.Q(ban_date__isnull=False),
violation_error_message='Banned users must have a ban_date set'
),
]
def save(self, *args, **kwargs):
if not self.user_id:
self.user_id = generate_random_id(User, "user_id")
@@ -631,6 +648,4 @@ class NotificationPreference(TrackedModel):
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
NotificationPreference.objects.create(user=instance)

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