mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 14:11:09 -05:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdbbca2add | ||
|
|
bf365693f8 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
---
|
||||
description: Comprehensive ThrillWiki Django project context including architecture, development patterns, business rules, and mandatory Context7 MCP integration workflow
|
||||
author: ThrillWiki Development Team
|
||||
version: 2.0
|
||||
globs: ["**/*.py", "**/*.html", "**/*.js", "**/*.css", "**/*.md"]
|
||||
tags: ["django", "architecture", "api-design", "business-rules", "context7-integration", "thrillwiki"]
|
||||
---
|
||||
|
||||
# ThrillWiki Django Project Context
|
||||
|
||||
## Objective
|
||||
This rule provides comprehensive context for the ThrillWiki project, defining core architecture patterns, business rules, development workflows, and mandatory integration requirements. It serves as the primary reference for maintaining consistency across all ThrillWiki development activities.
|
||||
|
||||
## Project Overview
|
||||
ThrillWiki is a comprehensive theme park database platform with user-generated content, expert moderation, and rich media support. Built with Django REST Framework, it serves 120+ API endpoints for parks, rides, companies, and user management.
|
||||
|
||||
## Core Architecture
|
||||
|
||||
### Technology Stack
|
||||
- **Backend**: Django 5.0+ with DRF, PostgreSQL + PostGIS, Redis caching, Celery tasks
|
||||
- **Frontend**: HTMX + AlpineJS + Tailwind CSS + Django-Cotton
|
||||
- 🚨 **CRITICAL**: NO React/Vue/Angular allowed
|
||||
- **Media**: Cloudflare Images using Direct Upload with variants and transformations
|
||||
- **Tracking**: pghistory for all model changes, TrackedModel base class
|
||||
- **Choices**: Rich Choice Objects system (NEVER use Django tuple choices)
|
||||
|
||||
### Domain Architecture
|
||||
- **Parks Domain**: `parks/`, companies (OPERATOR/PROPERTY_OWNER roles only)
|
||||
- **Rides Domain**: `rides/`, companies (MANUFACTURER/DESIGNER roles only)
|
||||
- **Core Apps**: `accounts/`, `media/`, `moderation/`, `core/`
|
||||
- 🚨 **CRITICAL BUSINESS RULE**: Never mix park/ride company roles - fundamental business rule violation
|
||||
|
||||
## Development Patterns
|
||||
|
||||
### Model Patterns
|
||||
- **Base Classes**: All models MUST inherit from TrackedModel
|
||||
- **Slug Handling**: Use SluggedModel for slugs with history tracking
|
||||
- **Location Data**: Use PostGIS for geographic data, separate location models
|
||||
- **Media Fields**: Use CloudflareImagesField for all image handling
|
||||
|
||||
### API Design Patterns
|
||||
- **URL Structure**: Nested URLs (`/parks/{slug}/rides/{slug}/`)
|
||||
- **Trailing Slashes**: MANDATORY trailing slashes on all endpoints
|
||||
- **Authentication**: Token-based with role hierarchy (USER/MODERATOR/ADMIN/SUPERUSER)
|
||||
- **Filtering**: Comprehensive filtering - rides (25+ parameters), parks (15+ parameters)
|
||||
- **Responses**: Standard DRF pagination, rich error responses with details
|
||||
- **Caching**: Multi-level (Redis, CDN, browser) with signal-based invalidation
|
||||
|
||||
### Choice System (MANDATORY)
|
||||
- **Implementation**: `RichChoiceField(choice_group="group_name", domain="domain_name")`
|
||||
- **Definition**: Domain-specific `choices.py` using RichChoice dataclass
|
||||
- **Registration**: `register_choices()` function in domain `__init__.py`
|
||||
- **Required Metadata**: color, icon, description, css_class (minimum)
|
||||
- 🚨 **FORBIDDEN**: NO tuple-based choices allowed anywhere in codebase
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Package Management
|
||||
- **Python Packages**: `uv add <package>` (NOT `pip install`)
|
||||
- **Server**: `uv run manage.py runserver_plus` (NOT `python manage.py`)
|
||||
- **Migrations**: `uv run manage.py makemigrations/migrate`
|
||||
- **Management**: ALWAYS use `uv run manage.py <command>`
|
||||
|
||||
## Business Rules
|
||||
|
||||
### Company Role Separation
|
||||
- **Parks Domain**: Only OPERATOR and PROPERTY_OWNER roles
|
||||
- **Rides Domain**: Only MANUFACTURER and DESIGNER roles
|
||||
- 🚨 **CRITICAL**: Never allow cross-domain company roles
|
||||
|
||||
### Data Integrity
|
||||
- **Model Changes**: All must be tracked via pghistory
|
||||
- **API Responses**: MUST use real database data (NEVER MOCK DATA)
|
||||
- **Geographic Data**: MUST use PostGIS for accuracy
|
||||
|
||||
## Frontend Constraints
|
||||
|
||||
### Architecture Requirements
|
||||
- **HTMX**: Dynamic updates and AJAX interactions
|
||||
- **AlpineJS**: Client-side state management
|
||||
- **Tailwind CSS**: Styling framework
|
||||
- **Progressive Enhancement**: Required approach
|
||||
|
||||
### Performance Targets
|
||||
- **First Contentful Paint**: < 1.5s
|
||||
- **Time to Interactive**: < 2s
|
||||
- **Compliance**: Core Web Vitals compliance
|
||||
- **Browser Support**: Latest 2 versions of major browsers
|
||||
|
||||
## Context7 MCP Integration (MANDATORY)
|
||||
|
||||
### Requirement
|
||||
🚨 **CRITICAL**: ALWAYS use Context7 MCP for documentation lookups before making changes
|
||||
|
||||
### Libraries Requiring Context7
|
||||
- **tailwindcss**: CSS utility classes, responsive design, component styling
|
||||
- **django**: Models, views, forms, URL patterns, Django-specific patterns
|
||||
- **django-cotton**: Component creation, template organization, Cotton-specific syntax
|
||||
- **htmx**: Dynamic updates, form handling, AJAX interactions
|
||||
- **alpinejs**: Client-side state management, reactive data, JavaScript interactions
|
||||
- **django-rest-framework**: API design, serializers, viewsets, DRF patterns
|
||||
- **postgresql**: Database queries, PostGIS functions, advanced SQL features
|
||||
- **postgis**: Geographic data handling and spatial queries
|
||||
- **redis**: Caching strategies, session management, performance optimization
|
||||
|
||||
### Mandatory Workflow Steps
|
||||
1. **Before editing/creating code**: Query Context7 for relevant library documentation
|
||||
2. **During debugging**: Use Context7 to verify syntax, patterns, and best practices
|
||||
3. **When implementing new features**: Reference Context7 for current API and method signatures
|
||||
4. **For performance issues**: Consult Context7 for optimization techniques and patterns
|
||||
5. **For geographic data handling**: Use Context7 for PostGIS functions and best practices
|
||||
6. **For caching strategies**: Refer to Context7 for Redis patterns and best practices
|
||||
7. **For database queries**: Utilize Context7 for PostgreSQL best practices and advanced SQL features
|
||||
|
||||
### Mandatory Scenarios
|
||||
- Creating new Django models or API endpoints
|
||||
- Implementing HTMX dynamic functionality
|
||||
- Writing AlpineJS reactive components
|
||||
- Designing responsive layouts with Tailwind CSS
|
||||
- Creating Django-Cotton components
|
||||
- Debugging CSS, JavaScript, or Django issues
|
||||
- Implementing caching or database optimizations
|
||||
- Handling geographic data with PostGIS
|
||||
- Utilizing Redis for session management
|
||||
- Implementing real-time features with WebSockets
|
||||
|
||||
### Context7 Commands
|
||||
1. **Resolve Library**: Always call `Context7:resolve-library-id` first to get correct library ID
|
||||
2. **Get Documentation**: Then use `Context7:get-library-docs` with appropriate topic parameter
|
||||
|
||||
### Example Topics by Library
|
||||
- **tailwindcss**: responsive design, flexbox, grid, animations
|
||||
- **django**: models, views, forms, admin, signals
|
||||
- **django-cotton**: components, templates, slots, props
|
||||
- **htmx**: hx-get, hx-post, hx-swap, hx-trigger, hx-target
|
||||
- **alpinejs**: x-data, x-show, x-if, x-for, x-model
|
||||
- **django-rest-framework**: serializers, viewsets, routers, permissions
|
||||
- **postgresql**: joins, indexes, transactions, window functions
|
||||
- **postgis**: geospatial queries, distance calculations, spatial indexes
|
||||
- **redis**: caching strategies, pub/sub, data structures
|
||||
|
||||
## Code Quality Standards
|
||||
|
||||
### Model Requirements
|
||||
- All models MUST inherit from TrackedModel
|
||||
- Use SluggedModel for entities with slugs and history tracking
|
||||
- Always use RichChoiceField instead of Django choices
|
||||
- Use CloudflareImagesField for all image handling
|
||||
- Use PostGIS fields and separate location models for geographic data
|
||||
|
||||
### API Requirements
|
||||
- MUST include trailing slashes and follow nested pattern
|
||||
- All responses MUST use real database queries
|
||||
- Implement comprehensive filtering and pagination
|
||||
- Use signal-based cache invalidation
|
||||
|
||||
### Development Workflow
|
||||
- Use uv for all Python package operations
|
||||
- Use runserver_plus for enhanced development server
|
||||
- Always use `uv run` for Django management commands
|
||||
- All functionality MUST work with progressive enhancement
|
||||
@@ -1,56 +0,0 @@
|
||||
---
|
||||
description: Condensed ThrillWiki Django project context with architecture, patterns, and mandatory Context7 integration
|
||||
author: ThrillWiki Development Team
|
||||
version: 2.1
|
||||
globs: ["**/*.py", "**/*.html", "**/*.js", "**/*.css", "**/*.md"]
|
||||
tags: ["django", "architecture", "context7-integration", "thrillwiki"]
|
||||
---
|
||||
|
||||
# ThrillWiki Django Project Context
|
||||
|
||||
## Project Overview
|
||||
Theme park database platform with Django REST Framework serving 120+ API endpoints for parks, rides, companies, and users.
|
||||
|
||||
## Core Architecture
|
||||
- **Backend**: Django 5.1+, DRF, PostgreSQL+PostGIS, Redis, Celery
|
||||
- **Frontend**: HTMX (V2+) + AlpineJS + Tailwind CSS (V4+) + Django-Cotton
|
||||
- 🚨 **ABSOLUTELY NO Custom JS** - use HTMX + AlpineJS ONLY
|
||||
- Clean, simple UX preferred
|
||||
- **Media**: Cloudflare Images with Direct Upload
|
||||
- **Tracking**: pghistory, TrackedModel base class
|
||||
- **Choices**: Rich Choice Objects (NEVER Django tuple choices)
|
||||
|
||||
## Development Patterns
|
||||
- **Models**: TrackedModel inheritance, SluggedModel for slugs, PostGIS for location
|
||||
- **APIs**: Nested URLs (`/parks/{slug}/rides/{slug}/`), mandatory trailing slashes
|
||||
- **Commands**: `uv add <package>`, `uv run manage.py <command>` (NOT pip/python)
|
||||
- **Choices**: `RichChoiceField(choice_group="name", domain="domain")` MANDATORY
|
||||
|
||||
## Business Rules
|
||||
🚨 **CRITICAL**: Company role separation - Parks (OPERATOR/PROPERTY_OWNER only), Rides (MANUFACTURER/DESIGNER only)
|
||||
|
||||
## Context7 MCP Integration (MANDATORY)
|
||||
|
||||
### Required Libraries
|
||||
tailwindcss, django, django-cotton, htmx, alpinejs, django-rest-framework, postgresql, postgis, redis
|
||||
|
||||
### Workflow
|
||||
1. **ALWAYS** call `Context7:resolve-library-id` first
|
||||
2. Then `Context7:get-library-docs` with topic parameter
|
||||
3. Required for: new models/APIs, HTMX functionality, AlpineJS components, Tailwind layouts, Cotton components, debugging, optimizations
|
||||
|
||||
### Example Topics
|
||||
- **tailwindcss**: responsive, flexbox, grid
|
||||
- **django**: models, views, forms
|
||||
- **htmx**: hx-get, hx-post, hx-swap, hx-target
|
||||
- **alpinejs**: x-data, x-show, x-if, x-for
|
||||
|
||||
## Standards
|
||||
- All models inherit TrackedModel
|
||||
- Real database data only (NO MOCKING)
|
||||
- RichChoiceField over Django choices
|
||||
- Progressive enhancement required
|
||||
|
||||
- We prefer to edit existing files instead of creating new ones.
|
||||
|
||||
YOU ARE STRICTLY AND ABSOLUTELY FORBIDDEN FROM IGNORING, BYPASSING, OR AVOIDING THESE RULES IN ANY WAY WITH NO EXCEPTIONS!!!
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -122,5 +122,4 @@ frontend/.env
|
||||
django-forwardemail/
|
||||
frontend/
|
||||
frontend
|
||||
.snapshots
|
||||
uv.lock
|
||||
.snapshots
|
||||
73
.replit
73
.replit
@@ -1,73 +0,0 @@
|
||||
modules = ["bash", "web", "nodejs-20", "python-3.13", "postgresql-16"]
|
||||
|
||||
[nix]
|
||||
channel = "stable-25_05"
|
||||
packages = [
|
||||
"freetype",
|
||||
"gdal",
|
||||
"geos",
|
||||
"gitFull",
|
||||
"lcms2",
|
||||
"libimagequant",
|
||||
"libjpeg",
|
||||
"libtiff",
|
||||
"libwebp",
|
||||
"libxcrypt",
|
||||
"openjpeg",
|
||||
"playwright-driver",
|
||||
"postgresql",
|
||||
"proj",
|
||||
"tcl",
|
||||
"tk",
|
||||
"uv",
|
||||
"zlib",
|
||||
]
|
||||
|
||||
[agent]
|
||||
expertMode = true
|
||||
|
||||
[workflows]
|
||||
runButton = "Project"
|
||||
|
||||
[[workflows.workflow]]
|
||||
name = "Project"
|
||||
mode = "parallel"
|
||||
author = "agent"
|
||||
|
||||
[[workflows.workflow.tasks]]
|
||||
task = "workflow.run"
|
||||
args = "ThrillWiki Server"
|
||||
|
||||
[[workflows.workflow]]
|
||||
name = "ThrillWiki Server"
|
||||
author = "agent"
|
||||
|
||||
[[workflows.workflow.tasks]]
|
||||
task = "shell.exec"
|
||||
args = "/home/runner/workspace/.venv/bin/python manage.py tailwind runserver 0.0.0.0:5000"
|
||||
waitForPort = 5000
|
||||
|
||||
[workflows.workflow.metadata]
|
||||
outputType = "webview"
|
||||
|
||||
[[ports]]
|
||||
localPort = 5000
|
||||
externalPort = 80
|
||||
|
||||
[[ports]]
|
||||
localPort = 41923
|
||||
externalPort = 3000
|
||||
|
||||
[[ports]]
|
||||
localPort = 45245
|
||||
externalPort = 3001
|
||||
|
||||
[deployment]
|
||||
deploymentTarget = "autoscale"
|
||||
run = [
|
||||
"gunicorn",
|
||||
"--bind=0.0.0.0:5000",
|
||||
"--reuse-port",
|
||||
"thrillwiki.wsgi:application",
|
||||
]
|
||||
build = ["uv", "pip", "install", "--system", "-r", "requirements.txt"]
|
||||
277
CI_README.md
Normal file
277
CI_README.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# ThrillWiki CI/CD System
|
||||
|
||||
This repository includes a **complete automated CI/CD system** that creates a Linux VM on Unraid and automatically deploys ThrillWiki when commits are pushed to GitHub.
|
||||
|
||||
## 🚀 Complete Automation (Unraid)
|
||||
|
||||
For **full automation** including VM creation on Unraid:
|
||||
|
||||
```bash
|
||||
./scripts/unraid/setup-complete-automation.sh
|
||||
```
|
||||
|
||||
This single command will:
|
||||
- ✅ Create and configure VM on Unraid
|
||||
- ✅ Install Ubuntu Server with all dependencies
|
||||
- ✅ Deploy ThrillWiki application
|
||||
- ✅ Set up automated CI/CD pipeline
|
||||
- ✅ Configure webhook listener
|
||||
- ✅ Test the entire system
|
||||
|
||||
## Manual Setup (Any Linux VM)
|
||||
|
||||
For manual setup on existing Linux VMs:
|
||||
|
||||
```bash
|
||||
./scripts/setup-vm-ci.sh
|
||||
```
|
||||
|
||||
## System Components
|
||||
|
||||
### 📁 Files Created
|
||||
|
||||
```
|
||||
scripts/
|
||||
├── ci-start.sh # Local development server startup
|
||||
├── webhook-listener.py # GitHub webhook listener
|
||||
├── vm-deploy.sh # VM deployment script
|
||||
├── setup-vm-ci.sh # Manual VM setup script
|
||||
├── unraid/
|
||||
│ ├── vm-manager.py # Unraid VM management
|
||||
│ └── setup-complete-automation.sh # Complete automation
|
||||
└── systemd/
|
||||
├── thrillwiki.service # Django app service
|
||||
└── thrillwiki-webhook.service # Webhook listener service
|
||||
|
||||
docs/
|
||||
├── VM_DEPLOYMENT_SETUP.md # Manual setup documentation
|
||||
└── UNRAID_COMPLETE_AUTOMATION.md # Complete automation guide
|
||||
```
|
||||
|
||||
### 🔄 Deployment Flow
|
||||
|
||||
**Complete Automation:**
|
||||
```
|
||||
GitHub Push → Webhook → Local Listener → SSH → Unraid VM → Deploy & Restart
|
||||
```
|
||||
|
||||
**Manual Setup:**
|
||||
```
|
||||
GitHub Push → Webhook → Local Listener → SSH to VM → Deploy Script → Server Restart
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Complete VM Automation**: Automatically creates VMs on Unraid
|
||||
- **Automatic Deployment**: Deploys on push to main branch
|
||||
- **Health Checks**: Verifies deployment success
|
||||
- **Rollback Support**: Automatic rollback on deployment failure
|
||||
- **Service Management**: Systemd integration for reliable service management
|
||||
- **Database Setup**: Automated PostgreSQL configuration
|
||||
- **Logging**: Comprehensive logging for debugging
|
||||
- **Security**: SSH key authentication and webhook secrets
|
||||
- **One-Command Setup**: Full automation with single script
|
||||
|
||||
## Usage
|
||||
|
||||
### Complete Automation (Recommended)
|
||||
|
||||
For Unraid users, run the complete automation:
|
||||
|
||||
```bash
|
||||
./scripts/unraid/setup-complete-automation.sh
|
||||
```
|
||||
|
||||
After setup, start the webhook listener:
|
||||
```bash
|
||||
./start-webhook.sh
|
||||
```
|
||||
|
||||
### Local Development
|
||||
|
||||
Start the local development server:
|
||||
|
||||
```bash
|
||||
./scripts/ci-start.sh
|
||||
```
|
||||
|
||||
### VM Management (Unraid)
|
||||
|
||||
```bash
|
||||
# Check VM status
|
||||
python3 scripts/unraid/vm-manager.py status
|
||||
|
||||
# Start/stop VM
|
||||
python3 scripts/unraid/vm-manager.py start
|
||||
python3 scripts/unraid/vm-manager.py stop
|
||||
|
||||
# Get VM IP
|
||||
python3 scripts/unraid/vm-manager.py ip
|
||||
```
|
||||
|
||||
### Service Management
|
||||
|
||||
On the VM:
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
ssh thrillwiki-vm "./scripts/vm-deploy.sh status"
|
||||
|
||||
# Restart service
|
||||
ssh thrillwiki-vm "./scripts/vm-deploy.sh restart"
|
||||
|
||||
# View logs
|
||||
ssh thrillwiki-vm "journalctl -u thrillwiki -f"
|
||||
```
|
||||
|
||||
### Manual VM Deployment
|
||||
|
||||
Deploy to VM manually:
|
||||
|
||||
```bash
|
||||
ssh thrillwiki-vm "cd thrillwiki && ./scripts/vm-deploy.sh"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Automated Configuration
|
||||
|
||||
The complete automation script creates all necessary configuration files:
|
||||
|
||||
- `***REMOVED***.unraid` - Unraid VM configuration
|
||||
- `***REMOVED***.webhook` - Webhook listener configuration
|
||||
- SSH keys and configuration
|
||||
- Service configurations
|
||||
|
||||
### Manual Environment Variables
|
||||
|
||||
For manual setup, create `***REMOVED***.webhook` file:
|
||||
|
||||
```bash
|
||||
WEBHOOK_PORT=9000
|
||||
WEBHOOK_SECRET=your_secret_here
|
||||
VM_HOST=your_vm_ip
|
||||
VM_USER=ubuntu
|
||||
VM_KEY_PATH=/path/to/ssh/key
|
||||
VM_PROJECT_PATH=/home/ubuntu/thrillwiki
|
||||
REPO_URL=https://github.com/username/repo.git
|
||||
DEPLOY_BRANCH=main
|
||||
```
|
||||
|
||||
### GitHub Webhook
|
||||
|
||||
Configure in your GitHub repository:
|
||||
- **URL**: `http://YOUR_PUBLIC_IP:9000/webhook`
|
||||
- **Content Type**: `application/json`
|
||||
- **Secret**: Your webhook secret
|
||||
- **Events**: Push events
|
||||
|
||||
## Requirements
|
||||
|
||||
### For Complete Automation
|
||||
- **Local Machine**: Python 3.8+, SSH client
|
||||
- **Unraid Server**: 6.8+ with VM support
|
||||
- **Resources**: 4GB RAM, 50GB disk minimum
|
||||
- **Ubuntu ISO**: Ubuntu Server 22.04 in `/mnt/user/isos/`
|
||||
|
||||
### For Manual Setup
|
||||
- **Local Machine**: Python 3.8+, SSH access to VM, Public IP
|
||||
- **Linux VM**: Ubuntu 20.04+, Python 3.8+, UV package manager, Git, SSH server
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Complete Automation Issues
|
||||
|
||||
1. **VM Creation Fails**
|
||||
```bash
|
||||
# Check Unraid VM support
|
||||
ssh unraid "virsh list --all"
|
||||
|
||||
# Verify Ubuntu ISO exists
|
||||
ssh unraid "ls -la /mnt/user/isos/ubuntu-*.iso"
|
||||
```
|
||||
|
||||
2. **VM Won't Start**
|
||||
```bash
|
||||
# Check VM status
|
||||
python3 scripts/unraid/vm-manager.py status
|
||||
|
||||
# Check Unraid logs
|
||||
ssh unraid "tail -f /var/log/libvirt/qemu/thrillwiki-vm.log"
|
||||
```
|
||||
|
||||
### General Issues
|
||||
|
||||
1. **SSH Connection Failed**
|
||||
```bash
|
||||
# Check SSH key permissions
|
||||
chmod 600 ~/.ssh/thrillwiki_vm
|
||||
|
||||
# Test connection
|
||||
ssh thrillwiki-vm
|
||||
```
|
||||
|
||||
2. **Webhook Not Receiving Events**
|
||||
```bash
|
||||
# Check if port is open
|
||||
sudo ufw allow 9000
|
||||
|
||||
# Verify webhook URL in GitHub
|
||||
curl -X GET http://localhost:9000/health
|
||||
```
|
||||
|
||||
3. **Service Won't Start**
|
||||
```bash
|
||||
# Check service logs
|
||||
ssh thrillwiki-vm "journalctl -u thrillwiki --no-pager"
|
||||
|
||||
# Manual start
|
||||
ssh thrillwiki-vm "cd thrillwiki && ./scripts/ci-start.sh"
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
- **Setup logs**: `logs/unraid-automation.log`
|
||||
- **Local webhook**: `logs/webhook.log`
|
||||
- **VM deployment**: `logs/deploy.log` (on VM)
|
||||
- **Django server**: `logs/django.log` (on VM)
|
||||
- **System logs**: `journalctl -u thrillwiki -f` (on VM)
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Automated SSH key generation and management
|
||||
- Dedicated keys for each connection (VM access, Unraid access)
|
||||
- No password authentication
|
||||
- Systemd security features enabled
|
||||
- Firewall configuration support
|
||||
- Secret management in environment files
|
||||
|
||||
## Documentation
|
||||
|
||||
- **Complete Automation**: [`docs/UNRAID_COMPLETE_AUTOMATION.md`](docs/UNRAID_COMPLETE_AUTOMATION.md)
|
||||
- **Manual Setup**: [`docs/VM_DEPLOYMENT_SETUP.md`](docs/VM_DEPLOYMENT_SETUP.md)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start Summary
|
||||
|
||||
### For Unraid Users (Complete Automation)
|
||||
```bash
|
||||
# One command to set up everything
|
||||
./scripts/unraid/setup-complete-automation.sh
|
||||
|
||||
# Start webhook listener
|
||||
./start-webhook.sh
|
||||
|
||||
# Push commits to auto-deploy!
|
||||
```
|
||||
|
||||
### For Existing VM Users
|
||||
```bash
|
||||
# Manual setup
|
||||
./scripts/setup-vm-ci.sh
|
||||
|
||||
# Configure webhook and push to deploy
|
||||
```
|
||||
|
||||
**The system will automatically deploy your Django application whenever you push commits to the main branch!** 🚀
|
||||
232
CRITICAL_ANALYSIS_HTMX_ALPINE.md
Normal file
232
CRITICAL_ANALYSIS_HTMX_ALPINE.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# Critical Analysis: Current HTMX + Alpine.js Implementation
|
||||
|
||||
## Executive Summary
|
||||
|
||||
After thorough analysis, the current HTMX + Alpine.js implementation has **significant gaps** compared to the React frontend functionality. While the foundation exists, there are critical missing pieces that prevent it from being a true replacement for the React frontend.
|
||||
|
||||
## Major Issues Identified
|
||||
|
||||
### 1. **Incomplete Component Parity** ❌
|
||||
|
||||
**React Frontend Has:**
|
||||
- Sophisticated park/ride cards with hover effects, ratings, status badges
|
||||
- Advanced search with autocomplete and real-time suggestions
|
||||
- Complex filtering UI with multiple filter types
|
||||
- Rich user profile management
|
||||
- Modal-based authentication flows
|
||||
- Theme switching with system preference detection
|
||||
- Responsive image handling with Next.js Image optimization
|
||||
|
||||
**Current Django Templates Have:**
|
||||
- Basic card layouts without advanced interactions
|
||||
- Simple search without autocomplete
|
||||
- Limited filtering capabilities
|
||||
- Basic user menus
|
||||
- No modal authentication system
|
||||
- Basic theme toggle
|
||||
|
||||
### 2. **Missing Critical Pages** ❌
|
||||
|
||||
**React Frontend Pages Not Implemented:**
|
||||
- `/profile` - User profile management
|
||||
- `/settings` - User settings and preferences
|
||||
- `/api-test` - API testing interface
|
||||
- `/test-ride` - Ride testing components
|
||||
- Advanced search results page
|
||||
- User dashboard/account management
|
||||
|
||||
**Current Django Only Has:**
|
||||
- Basic park/ride listing pages
|
||||
- Simple detail pages
|
||||
- Admin/moderation interfaces
|
||||
|
||||
### 3. **Inadequate State Management** ❌
|
||||
|
||||
**React Frontend Uses:**
|
||||
- Complex state management with custom hooks
|
||||
- Global authentication state
|
||||
- Theme provider with system detection
|
||||
- Search state with debouncing
|
||||
- Filter state with URL synchronization
|
||||
|
||||
**Current Alpine.js Has:**
|
||||
- Basic component-level state
|
||||
- Simple theme toggle
|
||||
- No global state management
|
||||
- No URL state synchronization
|
||||
- No proper error handling
|
||||
|
||||
### 4. **Poor API Integration** ❌
|
||||
|
||||
**React Frontend Features:**
|
||||
- TypeScript API clients with proper typing
|
||||
- Error handling and loading states
|
||||
- Optimistic updates
|
||||
- Proper authentication headers
|
||||
- Response caching
|
||||
|
||||
**Current HTMX Implementation:**
|
||||
- Basic HTMX requests without error handling
|
||||
- No loading states
|
||||
- No proper authentication integration
|
||||
- No response validation
|
||||
- No caching strategy
|
||||
|
||||
### 5. **Missing Advanced UI Components** ❌
|
||||
|
||||
**React Frontend Components Missing:**
|
||||
- Advanced data tables with sorting/filtering
|
||||
- Image galleries with lightbox
|
||||
- Multi-step forms
|
||||
- Rich text editors
|
||||
- Date/time pickers
|
||||
- Advanced modals and dialogs
|
||||
- Toast notifications system
|
||||
- Skeleton loading states
|
||||
|
||||
### 6. **Inadequate Mobile Experience** ❌
|
||||
|
||||
**React Frontend Mobile Features:**
|
||||
- Responsive design with proper breakpoints
|
||||
- Touch-optimized interactions
|
||||
- Mobile-specific navigation patterns
|
||||
- Swipe gestures
|
||||
- Mobile-optimized forms
|
||||
|
||||
**Current Implementation:**
|
||||
- Basic responsive layout
|
||||
- No touch optimizations
|
||||
- Simple mobile menu
|
||||
- No mobile-specific interactions
|
||||
|
||||
## Specific Technical Gaps
|
||||
|
||||
### Authentication System
|
||||
```html
|
||||
<!-- Current: Basic login links -->
|
||||
<a href="{% url 'account_login' %}">Login</a>
|
||||
|
||||
<!-- Needed: Modal-based auth like React -->
|
||||
<div x-data="authModal()" x-show="open" class="modal">
|
||||
<!-- Complex auth flow with validation -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### Search Functionality
|
||||
```javascript
|
||||
// Current: Basic search
|
||||
Alpine.data('searchComponent', () => ({
|
||||
query: '',
|
||||
async search() {
|
||||
// Basic fetch without proper error handling
|
||||
}
|
||||
}))
|
||||
|
||||
// Needed: Advanced search like React
|
||||
Alpine.data('advancedSearch', () => ({
|
||||
query: '',
|
||||
filters: {},
|
||||
suggestions: [],
|
||||
loading: false,
|
||||
debounceTimer: null,
|
||||
// Complex search logic with debouncing, caching, etc.
|
||||
}))
|
||||
```
|
||||
|
||||
### Component Architecture
|
||||
```html
|
||||
<!-- Current: Basic templates -->
|
||||
<div class="card">
|
||||
<h3>{{ park.name }}</h3>
|
||||
</div>
|
||||
|
||||
<!-- Needed: Rich components like React -->
|
||||
<div x-data="parkCard({{ park|json }})" class="park-card">
|
||||
<!-- Complex interactions, animations, state management -->
|
||||
</div>
|
||||
```
|
||||
|
||||
## Performance Issues
|
||||
|
||||
### 1. **No Code Splitting**
|
||||
- React frontend uses dynamic imports and code splitting
|
||||
- Current implementation loads everything upfront
|
||||
- No lazy loading of components or routes
|
||||
|
||||
### 2. **Inefficient HTMX Usage**
|
||||
- Multiple HTMX requests for simple interactions
|
||||
- No request batching or optimization
|
||||
- No proper caching headers
|
||||
|
||||
### 3. **Poor Asset Management**
|
||||
- No asset optimization
|
||||
- No image optimization (missing Next.js Image equivalent)
|
||||
- No CSS/JS minification strategy
|
||||
|
||||
## Missing Developer Experience
|
||||
|
||||
### 1. **No Type Safety**
|
||||
- React frontend has full TypeScript support
|
||||
- Current implementation has no type checking
|
||||
- No API contract validation
|
||||
|
||||
### 2. **Poor Error Handling**
|
||||
- No global error boundaries
|
||||
- No proper error reporting
|
||||
- No user-friendly error messages
|
||||
|
||||
### 3. **No Testing Strategy**
|
||||
- React frontend has component testing
|
||||
- Current implementation has no frontend tests
|
||||
- No integration testing
|
||||
|
||||
## Critical Missing Features
|
||||
|
||||
### 1. **Real-time Features**
|
||||
- No WebSocket integration
|
||||
- No live updates
|
||||
- No real-time notifications
|
||||
|
||||
### 2. **Advanced Interactions**
|
||||
- No drag and drop
|
||||
- No complex animations
|
||||
- No keyboard navigation
|
||||
- No accessibility features
|
||||
|
||||
### 3. **Data Management**
|
||||
- No client-side caching
|
||||
- No optimistic updates
|
||||
- No offline support
|
||||
- No data synchronization
|
||||
|
||||
## Recommended Action Plan
|
||||
|
||||
### Phase 1: Critical Component Migration (High Priority)
|
||||
1. **Authentication System** - Implement modal-based auth with proper validation
|
||||
2. **Advanced Search** - Build autocomplete with debouncing and caching
|
||||
3. **User Profile/Settings** - Create comprehensive user management
|
||||
4. **Enhanced Cards** - Implement rich park/ride cards with interactions
|
||||
|
||||
### Phase 2: Advanced Features (Medium Priority)
|
||||
1. **State Management** - Implement proper global state with Alpine stores
|
||||
2. **API Integration** - Build robust API client with error handling
|
||||
3. **Mobile Optimization** - Enhance mobile experience
|
||||
4. **Performance** - Implement caching and optimization
|
||||
|
||||
### Phase 3: Polish and Testing (Low Priority)
|
||||
1. **Error Handling** - Implement comprehensive error boundaries
|
||||
2. **Testing** - Add frontend testing suite
|
||||
3. **Accessibility** - Ensure WCAG compliance
|
||||
4. **Documentation** - Create comprehensive component docs
|
||||
|
||||
## Conclusion
|
||||
|
||||
The current HTMX + Alpine.js implementation is **NOT ready** to replace the React frontend. It's missing approximately **60-70%** of the functionality and sophistication of the React application.
|
||||
|
||||
A proper migration requires:
|
||||
- **3-4 weeks of intensive development**
|
||||
- **Complete rewrite of most components**
|
||||
- **New architecture for state management**
|
||||
- **Comprehensive testing and optimization**
|
||||
|
||||
The existing Django templates are a good foundation, but they need **significant enhancement** to match the React frontend's capabilities.
|
||||
258
FRONTEND_MIGRATION_PLAN.md
Normal file
258
FRONTEND_MIGRATION_PLAN.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# Frontend Migration Plan: React/Next.js to HTMX + Alpine.js
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Based on my analysis, this project already has a **fully functional HTMX + Alpine.js Django backend** with comprehensive templates. The task is to migrate the separate Next.js React frontend (`frontend/` directory) to integrate seamlessly with the existing Django HTMX + Alpine.js architecture.
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### ✅ Django Backend (Already Complete)
|
||||
- **HTMX Integration**: Already implemented with proper headers and partial templates
|
||||
- **Alpine.js Components**: Extensive use of Alpine.js for interactivity
|
||||
- **Template Structure**: Comprehensive template hierarchy with partials
|
||||
- **Authentication**: Complete auth system with modals and forms
|
||||
- **Styling**: Tailwind CSS with dark mode support
|
||||
- **Components**: Reusable components for cards, pagination, forms, etc.
|
||||
|
||||
### 🔄 React Frontend (To Be Migrated)
|
||||
- **Next.js App Router**: Modern React application structure
|
||||
- **Component Library**: Extensive UI components using shadcn/ui
|
||||
- **Authentication**: React-based auth hooks and providers
|
||||
- **Theme Management**: React theme provider system
|
||||
- **API Integration**: TypeScript API clients for Django backend
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Template Enhancement (Extend Django Templates)
|
||||
|
||||
Instead of replacing the existing Django templates, we'll enhance them to match the React frontend's design and functionality.
|
||||
|
||||
#### 1.1 Header Component Migration
|
||||
**Current Django**: Basic header with navigation
|
||||
**React Frontend**: Advanced header with browse menu, search, theme toggle, user dropdown
|
||||
|
||||
**Action**: Enhance `backend/templates/base/base.html` header section
|
||||
|
||||
#### 1.2 Component Library Integration
|
||||
**Current Django**: Basic components
|
||||
**React Frontend**: Rich component library (buttons, cards, modals, etc.)
|
||||
|
||||
**Action**: Create Django template components matching shadcn/ui design system
|
||||
|
||||
#### 1.3 Advanced Interactivity
|
||||
**Current Django**: Basic Alpine.js usage
|
||||
**React Frontend**: Complex state management and interactions
|
||||
|
||||
**Action**: Enhance Alpine.js components with advanced patterns
|
||||
|
||||
### Phase 2: Django View Enhancements
|
||||
|
||||
#### 2.1 API Response Optimization
|
||||
- Enhance existing Django views to support both full page and HTMX partial responses
|
||||
- Implement proper JSON responses for Alpine.js components
|
||||
- Add advanced filtering and search capabilities
|
||||
|
||||
#### 2.2 Authentication Flow
|
||||
- Enhance existing Django auth to match React frontend UX
|
||||
- Implement modal-based login/signup (already partially done)
|
||||
- Add proper error handling and validation
|
||||
|
||||
### Phase 3: Frontend Asset Migration
|
||||
|
||||
#### 3.1 Static Assets
|
||||
- Migrate React component styles to Django static files
|
||||
- Enhance Tailwind configuration
|
||||
- Add missing JavaScript utilities
|
||||
|
||||
#### 3.2 Alpine.js Store Management
|
||||
- Implement global state management using Alpine.store()
|
||||
- Create reusable Alpine.js components using Alpine.data()
|
||||
- Add proper event handling and communication
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Analyze Component Gaps
|
||||
Compare React components with Django templates to identify missing functionality:
|
||||
|
||||
1. **Browse Menu**: React has sophisticated browse dropdown
|
||||
2. **Search Functionality**: React has advanced search with autocomplete
|
||||
3. **Theme Toggle**: React has system/light/dark theme support
|
||||
4. **User Management**: React has comprehensive user profile management
|
||||
5. **Modal System**: React has advanced modal components
|
||||
6. **Form Handling**: React has sophisticated form validation
|
||||
|
||||
### Step 2: Enhance Django Templates
|
||||
|
||||
#### Base Template Enhancements
|
||||
```html
|
||||
<!-- Enhanced header with browse menu -->
|
||||
<div class="browse-menu" x-data="browseMenu()">
|
||||
<!-- Implement React-style browse menu -->
|
||||
</div>
|
||||
|
||||
<!-- Enhanced search with autocomplete -->
|
||||
<div class="search-container" x-data="searchComponent()">
|
||||
<!-- Implement React-style search -->
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Alpine.js Component Library
|
||||
```javascript
|
||||
// Global Alpine.js components
|
||||
Alpine.data('browseMenu', () => ({
|
||||
open: false,
|
||||
toggle() { this.open = !this.open }
|
||||
}))
|
||||
|
||||
Alpine.data('searchComponent', () => ({
|
||||
query: '',
|
||||
results: [],
|
||||
async search() {
|
||||
// Implement search logic
|
||||
}
|
||||
}))
|
||||
```
|
||||
|
||||
### Step 3: Django View Enhancements
|
||||
|
||||
#### Enhanced Views for HTMX
|
||||
```python
|
||||
def enhanced_park_list(request):
|
||||
if request.headers.get('HX-Request'):
|
||||
# Return partial template for HTMX
|
||||
return render(request, 'parks/partials/park_list.html', context)
|
||||
# Return full page
|
||||
return render(request, 'parks/park_list.html', context)
|
||||
```
|
||||
|
||||
### Step 4: Component Migration Priority
|
||||
|
||||
1. **Header Component** (High Priority)
|
||||
- Browse menu with categories
|
||||
- Advanced search with autocomplete
|
||||
- User dropdown with profile management
|
||||
- Theme toggle with system preference
|
||||
|
||||
2. **Navigation Components** (High Priority)
|
||||
- Mobile menu with slide-out
|
||||
- Breadcrumb navigation
|
||||
- Tab navigation
|
||||
|
||||
3. **Form Components** (Medium Priority)
|
||||
- Advanced form validation
|
||||
- File upload components
|
||||
- Multi-step forms
|
||||
|
||||
4. **Data Display Components** (Medium Priority)
|
||||
- Advanced card layouts
|
||||
- Data tables with sorting/filtering
|
||||
- Pagination components
|
||||
|
||||
5. **Modal and Dialog Components** (Low Priority)
|
||||
- Confirmation dialogs
|
||||
- Image galleries
|
||||
- Settings panels
|
||||
|
||||
## Technical Implementation Details
|
||||
|
||||
### HTMX Patterns to Implement
|
||||
|
||||
1. **Lazy Loading**
|
||||
```html
|
||||
<div hx-get="/api/parks/" hx-trigger="intersect" hx-swap="innerHTML">
|
||||
Loading parks...
|
||||
</div>
|
||||
```
|
||||
|
||||
2. **Infinite Scroll**
|
||||
```html
|
||||
<div hx-get="/api/parks/?page=2" hx-trigger="revealed" hx-swap="beforeend">
|
||||
Load more...
|
||||
</div>
|
||||
```
|
||||
|
||||
3. **Live Search**
|
||||
```html
|
||||
<input hx-get="/api/search/" hx-trigger="input changed delay:300ms"
|
||||
hx-target="#search-results">
|
||||
```
|
||||
|
||||
### Alpine.js Patterns to Implement
|
||||
|
||||
1. **Global State Management**
|
||||
```javascript
|
||||
Alpine.store('app', {
|
||||
user: null,
|
||||
theme: 'system',
|
||||
searchQuery: ''
|
||||
})
|
||||
```
|
||||
|
||||
2. **Reusable Components**
|
||||
```javascript
|
||||
Alpine.data('modal', () => ({
|
||||
open: false,
|
||||
show() { this.open = true },
|
||||
hide() { this.open = false }
|
||||
}))
|
||||
```
|
||||
|
||||
## File Structure After Migration
|
||||
|
||||
```
|
||||
backend/
|
||||
├── templates/
|
||||
│ ├── base/
|
||||
│ │ ├── base.html (enhanced)
|
||||
│ │ └── components/
|
||||
│ │ ├── header.html
|
||||
│ │ ├── footer.html
|
||||
│ │ ├── navigation.html
|
||||
│ │ └── search.html
|
||||
│ ├── components/
|
||||
│ │ ├── ui/
|
||||
│ │ │ ├── button.html
|
||||
│ │ │ ├── card.html
|
||||
│ │ │ ├── modal.html
|
||||
│ │ │ └── form.html
|
||||
│ │ └── layout/
|
||||
│ │ ├── browse_menu.html
|
||||
│ │ └── user_menu.html
|
||||
│ └── partials/
|
||||
│ ├── htmx/
|
||||
│ └── alpine/
|
||||
├── static/
|
||||
│ ├── js/
|
||||
│ │ ├── alpine-components.js
|
||||
│ │ ├── htmx-config.js
|
||||
│ │ └── app.js
|
||||
│ └── css/
|
||||
│ ├── components.css
|
||||
│ └── tailwind.css
|
||||
```
|
||||
|
||||
## Success Metrics
|
||||
|
||||
1. **Functionality Parity**: All React frontend features work in Django templates
|
||||
2. **Design Consistency**: Visual design matches React frontend exactly
|
||||
3. **Performance**: Page load times improved due to server-side rendering
|
||||
4. **User Experience**: Smooth interactions with HTMX and Alpine.js
|
||||
5. **Maintainability**: Clean, reusable template components
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
- **Phase 1**: Template Enhancement (3-4 days)
|
||||
- **Phase 2**: Django View Enhancements (2-3 days)
|
||||
- **Phase 3**: Frontend Asset Migration (2-3 days)
|
||||
- **Testing & Refinement**: 2-3 days
|
||||
|
||||
**Total Estimated Time**: 9-13 days
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Immediate**: Start with header component migration
|
||||
2. **Priority**: Focus on high-impact components first
|
||||
3. **Testing**: Implement comprehensive testing for each migrated component
|
||||
4. **Documentation**: Update all documentation to reflect new architecture
|
||||
|
||||
This migration will result in a unified, server-rendered application with the rich interactivity of the React frontend but the performance and simplicity of HTMX + Alpine.js.
|
||||
207
MIGRATION_IMPLEMENTATION_SUMMARY.md
Normal file
207
MIGRATION_IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Frontend Migration Implementation Summary
|
||||
|
||||
## What We've Accomplished ✅
|
||||
|
||||
### 1. **Critical Analysis Completed**
|
||||
- Identified that current HTMX + Alpine.js implementation was missing **60-70%** of React frontend functionality
|
||||
- Documented specific gaps in authentication, search, state management, and UI components
|
||||
- Created detailed comparison between React and Django implementations
|
||||
|
||||
### 2. **Enhanced Authentication System** 🎯
|
||||
**Problem**: Django only had basic page-based login forms
|
||||
**Solution**: Created sophisticated modal-based authentication system
|
||||
|
||||
**Files Created/Modified:**
|
||||
- `backend/templates/components/auth/auth-modal.html` - Complete modal auth component
|
||||
- `backend/static/js/alpine-components.js` - Enhanced with `authModal()` Alpine component
|
||||
- `backend/templates/base/base.html` - Added global auth modal
|
||||
- `backend/templates/components/layout/enhanced_header.html` - Updated to use modal auth
|
||||
|
||||
**Features Implemented:**
|
||||
- Modal-based login/register (matches React AuthDialog)
|
||||
- Social authentication integration (Google, Discord)
|
||||
- Form validation and error handling
|
||||
- Password visibility toggle
|
||||
- Smooth transitions and animations
|
||||
- Global accessibility via `window.authModal`
|
||||
|
||||
### 3. **Advanced Toast Notification System** 🎯
|
||||
**Problem**: No toast notification system like React's Sonner
|
||||
**Solution**: Created comprehensive toast system with progress bars
|
||||
|
||||
**Files Created:**
|
||||
- `backend/templates/components/ui/toast-container.html` - Toast UI component
|
||||
- Enhanced Alpine.js with global toast store and component
|
||||
|
||||
**Features Implemented:**
|
||||
- Multiple toast types (success, error, warning, info)
|
||||
- Progress bar animations
|
||||
- Auto-dismiss with configurable duration
|
||||
- Smooth slide-in/out animations
|
||||
- Global store for app-wide access
|
||||
|
||||
### 4. **Enhanced Alpine.js Architecture** 🎯
|
||||
**Problem**: Basic Alpine.js components without sophisticated state management
|
||||
**Solution**: Created comprehensive component library
|
||||
|
||||
**Components Added:**
|
||||
- `authModal()` - Complete authentication flow
|
||||
- Enhanced `toast()` - Advanced notification system
|
||||
- Global stores for app state and toast management
|
||||
- Improved error handling and API integration
|
||||
|
||||
### 5. **Improved Header Component** 🎯
|
||||
**Problem**: Header didn't match React frontend sophistication
|
||||
**Solution**: Enhanced header with modal integration
|
||||
|
||||
**Features Added:**
|
||||
- Modal authentication buttons (instead of page redirects)
|
||||
- Proper Alpine.js integration
|
||||
- Maintained all existing functionality (browse menu, search, theme toggle)
|
||||
|
||||
## Current State Assessment
|
||||
|
||||
### ✅ **Completed Components**
|
||||
1. **Authentication System** - Modal-based auth matching React functionality
|
||||
2. **Toast Notifications** - Advanced toast system with animations
|
||||
3. **Theme Management** - Already working well
|
||||
4. **Header Navigation** - Enhanced with modal integration
|
||||
5. **Base Template Structure** - Solid foundation with global components
|
||||
|
||||
### ⚠️ **Partially Complete**
|
||||
1. **Search Functionality** - Basic HTMX search exists, needs autocomplete enhancement
|
||||
2. **User Profile/Settings** - Basic pages exist, need React-level sophistication
|
||||
3. **Card Components** - Basic cards exist, need hover effects and advanced interactions
|
||||
|
||||
### ❌ **Still Missing (High Priority)**
|
||||
1. **Advanced Search with Autocomplete** - React has sophisticated search with suggestions
|
||||
2. **Enhanced Park/Ride Cards** - Need hover effects, animations, better interactions
|
||||
3. **User Profile Management** - React has comprehensive profile editing
|
||||
4. **Settings Page** - React has advanced settings with multiple sections
|
||||
5. **Mobile Optimization** - Need touch-optimized interactions
|
||||
6. **Loading States** - Need skeleton loaders and proper loading indicators
|
||||
|
||||
### ❌ **Still Missing (Medium Priority)**
|
||||
1. **Advanced Filtering UI** - React has complex filter interfaces
|
||||
2. **Image Galleries** - React has lightbox and advanced image handling
|
||||
3. **Data Tables** - React has sortable, filterable tables
|
||||
4. **Form Validation** - Need client-side validation matching React
|
||||
5. **Pagination Components** - Need enhanced pagination with proper state
|
||||
|
||||
## Next Steps for Complete Migration
|
||||
|
||||
### Phase 1: Critical Missing Components (1-2 weeks)
|
||||
|
||||
#### 1. Enhanced Search with Autocomplete
|
||||
```javascript
|
||||
// Need to implement in Alpine.js
|
||||
Alpine.data('advancedSearch', () => ({
|
||||
query: '',
|
||||
suggestions: [],
|
||||
loading: false,
|
||||
showSuggestions: false,
|
||||
// Advanced search logic with debouncing, caching
|
||||
}))
|
||||
```
|
||||
|
||||
#### 2. Enhanced Park/Ride Cards
|
||||
```html
|
||||
<!-- Need to create sophisticated card component -->
|
||||
<div x-data="parkCard({{ park|json }})" class="park-card">
|
||||
<!-- Hover effects, animations, interactions -->
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 3. User Profile/Settings Pages
|
||||
- Create comprehensive profile editing interface
|
||||
- Add avatar upload with preview
|
||||
- Implement settings sections (privacy, notifications, etc.)
|
||||
|
||||
### Phase 2: Advanced Features (2-3 weeks)
|
||||
|
||||
#### 1. Advanced Filtering System
|
||||
- Multi-select filters
|
||||
- Range sliders
|
||||
- Date pickers
|
||||
- URL state synchronization
|
||||
|
||||
#### 2. Enhanced Mobile Experience
|
||||
- Touch-optimized interactions
|
||||
- Swipe gestures
|
||||
- Mobile-specific navigation patterns
|
||||
|
||||
#### 3. Loading States and Skeletons
|
||||
- Skeleton loading components
|
||||
- Proper loading indicators
|
||||
- Optimistic updates
|
||||
|
||||
### Phase 3: Polish and Optimization (1 week)
|
||||
|
||||
#### 1. Performance Optimization
|
||||
- Lazy loading
|
||||
- Image optimization
|
||||
- Request batching
|
||||
|
||||
#### 2. Accessibility Improvements
|
||||
- ARIA labels
|
||||
- Keyboard navigation
|
||||
- Screen reader support
|
||||
|
||||
#### 3. Testing and Documentation
|
||||
- Component testing
|
||||
- Integration testing
|
||||
- Comprehensive documentation
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Current Stack
|
||||
- **Backend**: Django with HTMX middleware
|
||||
- **Frontend**: HTMX + Alpine.js + Tailwind CSS
|
||||
- **Components**: shadcn/ui-inspired design system
|
||||
- **State Management**: Alpine.js stores + component-level state
|
||||
- **Authentication**: Modal-based with social auth integration
|
||||
|
||||
### Key Patterns Established
|
||||
1. **Global Component Access**: `window.authModal` pattern for cross-component communication
|
||||
2. **Store-based State**: Alpine.store() for global state management
|
||||
3. **HTMX + Alpine Integration**: Seamless server-client interaction
|
||||
4. **Component Templates**: Reusable Django template components
|
||||
5. **Progressive Enhancement**: Works without JavaScript, enhanced with it
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### ✅ **Achieved**
|
||||
- Modal authentication system (100% React parity)
|
||||
- Toast notification system (100% React parity)
|
||||
- Theme management (100% React parity)
|
||||
- Base template architecture (solid foundation)
|
||||
|
||||
### 🎯 **In Progress**
|
||||
- Search functionality (60% complete)
|
||||
- Card components (40% complete)
|
||||
- User management (30% complete)
|
||||
|
||||
### ❌ **Not Started**
|
||||
- Advanced filtering (0% complete)
|
||||
- Mobile optimization (0% complete)
|
||||
- Loading states (0% complete)
|
||||
|
||||
## Estimated Completion Time
|
||||
|
||||
**Total Remaining Work**: 4-6 weeks
|
||||
- **Phase 1 (Critical)**: 1-2 weeks
|
||||
- **Phase 2 (Advanced)**: 2-3 weeks
|
||||
- **Phase 3 (Polish)**: 1 week
|
||||
|
||||
## Conclusion
|
||||
|
||||
We've successfully implemented the **most critical missing piece** - the authentication system - which was a major gap between the React and Django implementations. The foundation is now solid with:
|
||||
|
||||
1. **Sophisticated modal authentication** matching React functionality
|
||||
2. **Advanced toast notification system** with animations and global state
|
||||
3. **Enhanced Alpine.js architecture** with proper component patterns
|
||||
4. **Solid template structure** for future component development
|
||||
|
||||
The remaining work is primarily about **enhancing existing components** rather than building fundamental architecture. The hardest part (authentication and global state management) is complete.
|
||||
|
||||
**Recommendation**: Continue with Phase 1 implementation focusing on search enhancement and card component improvements, as these will provide the most visible user experience improvements.
|
||||
443
README.md
443
README.md
@@ -1,87 +1,200 @@
|
||||
# ThrillWiki Backend
|
||||
# ThrillWiki Django + Vue.js Monorepo
|
||||
|
||||
Django REST API backend for the ThrillWiki monorepo.
|
||||
A comprehensive theme park and roller coaster information system built with a modern monorepo architecture combining Django REST API backend with Vue.js frontend.
|
||||
|
||||
## 🏗️ Architecture
|
||||
## 🏗️ Architecture Overview
|
||||
|
||||
This backend follows Django best practices with a modular app structure:
|
||||
This project uses a monorepo structure that cleanly separates backend and frontend concerns while maintaining shared resources and documentation:
|
||||
|
||||
```
|
||||
backend/
|
||||
├── apps/ # Django applications
|
||||
│ ├── accounts/ # User management
|
||||
│ ├── parks/ # Theme park data
|
||||
│ ├── rides/ # Ride information
|
||||
│ ├── moderation/ # Content moderation
|
||||
│ ├── location/ # Geographic data
|
||||
│ ├── media/ # File management
|
||||
│ ├── email_service/ # Email functionality
|
||||
│ └── core/ # Core utilities
|
||||
├── config/ # Django configuration
|
||||
│ ├── django/ # Settings files
|
||||
│ └── settings/ # Modular settings
|
||||
├── templates/ # Django templates
|
||||
├── static/ # Static files
|
||||
└── tests/ # Test files
|
||||
thrillwiki-monorepo/
|
||||
├── backend/ # Django REST API (Port 8000)
|
||||
│ ├── apps/ # Modular Django applications
|
||||
│ ├── config/ # Django settings and configuration
|
||||
│ ├── templates/ # Django templates
|
||||
│ └── static/ # Static assets
|
||||
├── frontend/ # Vue.js SPA (Port 5174)
|
||||
│ ├── src/ # Vue.js source code
|
||||
│ ├── public/ # Static assets
|
||||
│ └── dist/ # Build output
|
||||
├── shared/ # Shared resources and documentation
|
||||
│ ├── docs/ # Comprehensive documentation
|
||||
│ ├── scripts/ # Development and deployment scripts
|
||||
│ ├── config/ # Shared configuration
|
||||
│ └── media/ # Shared media files
|
||||
├── architecture/ # Architecture documentation
|
||||
└── profiles/ # Development profiles
|
||||
```
|
||||
|
||||
## 🛠️ Technology Stack
|
||||
|
||||
- **Django 5.0+** - Web framework
|
||||
- **Django REST Framework** - API framework
|
||||
- **PostgreSQL** - Primary database
|
||||
- **Redis** - Caching and sessions
|
||||
- **UV** - Python package management
|
||||
- **Celery** - Background task processing
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.11+
|
||||
- [uv](https://docs.astral.sh/uv/) package manager
|
||||
- PostgreSQL 14+
|
||||
- Redis 6+
|
||||
- **Python 3.11+** with [uv](https://docs.astral.sh/uv/) for backend dependencies
|
||||
- **Node.js 18+** with [pnpm](https://pnpm.io/) for frontend dependencies
|
||||
- **PostgreSQL 14+** (optional, defaults to SQLite for development)
|
||||
- **Redis 6+** (optional, for caching and sessions)
|
||||
|
||||
### Setup
|
||||
### Development Setup
|
||||
|
||||
1. **Install dependencies**
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd thrillwiki-monorepo
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
# Install frontend dependencies
|
||||
pnpm install
|
||||
|
||||
# Install backend dependencies
|
||||
cd backend && uv sync && cd ..
|
||||
```
|
||||
|
||||
3. **Environment configuration**
|
||||
```bash
|
||||
# Copy environment files
|
||||
cp .env.example .env
|
||||
cp backend/.env.example backend/.env
|
||||
cp frontend/.env.development frontend/.env.local
|
||||
|
||||
# Edit .env files with your settings
|
||||
```
|
||||
|
||||
4. **Database setup**
|
||||
```bash
|
||||
cd backend
|
||||
uv sync
|
||||
```
|
||||
|
||||
2. **Environment configuration**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
```
|
||||
|
||||
3. **Database setup**
|
||||
```bash
|
||||
uv run manage.py migrate
|
||||
uv run manage.py createsuperuser
|
||||
cd ..
|
||||
```
|
||||
|
||||
4. **Start development server**
|
||||
5. **Start development servers**
|
||||
```bash
|
||||
uv run manage.py runserver
|
||||
# Start both servers concurrently
|
||||
pnpm run dev
|
||||
|
||||
# Or start individually
|
||||
pnpm run dev:frontend # Vue.js on :5174
|
||||
pnpm run dev:backend # Django on :8000
|
||||
```
|
||||
|
||||
## 📁 Project Structure Details
|
||||
|
||||
### Backend (`/backend`)
|
||||
- **Django 5.0+** with REST Framework for API development
|
||||
- **Modular app architecture** with separate apps for parks, rides, accounts, etc.
|
||||
- **UV package management** for fast, reliable Python dependency management
|
||||
- **PostgreSQL/SQLite** database with comprehensive entity relationships
|
||||
- **Redis** for caching, sessions, and background tasks
|
||||
- **Comprehensive API** with frontend serializers for camelCase conversion
|
||||
|
||||
### Frontend (`/frontend`)
|
||||
- **Vue 3** with Composition API and `<script setup>` syntax
|
||||
- **TypeScript** for type safety and better developer experience
|
||||
- **Vite** for lightning-fast development and optimized production builds
|
||||
- **Tailwind CSS** with custom design system and dark mode support
|
||||
- **Pinia** for state management with modular stores
|
||||
- **Vue Router** for client-side routing
|
||||
- **Comprehensive UI component library** with shadcn-vue components
|
||||
|
||||
### Shared Resources (`/shared`)
|
||||
- **Documentation** - Comprehensive guides and API documentation
|
||||
- **Development scripts** - Automated setup, build, and deployment scripts
|
||||
- **Configuration** - Shared Docker, CI/CD, and infrastructure configs
|
||||
- **Media management** - Centralized media file handling and optimization
|
||||
|
||||
## 🛠️ Development Workflow
|
||||
|
||||
### Available Scripts
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm run dev # Start both servers concurrently
|
||||
pnpm run dev:frontend # Frontend only (:5174)
|
||||
pnpm run dev:backend # Backend only (:8000)
|
||||
|
||||
# Building
|
||||
pnpm run build # Build frontend for production
|
||||
pnpm run build:staging # Build for staging environment
|
||||
pnpm run build:production # Build for production environment
|
||||
|
||||
# Testing
|
||||
pnpm run test # Run all tests
|
||||
pnpm run test:frontend # Frontend unit and E2E tests
|
||||
pnpm run test:backend # Backend unit and integration tests
|
||||
|
||||
# Code Quality
|
||||
pnpm run lint # Lint all code
|
||||
pnpm run type-check # TypeScript type checking
|
||||
|
||||
# Setup and Maintenance
|
||||
pnpm run install:all # Install all dependencies
|
||||
./shared/scripts/dev/setup-dev.sh # Full development setup
|
||||
./shared/scripts/dev/start-all.sh # Start all services
|
||||
```
|
||||
|
||||
### Backend Development
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Django management commands
|
||||
uv run manage.py migrate
|
||||
uv run manage.py makemigrations
|
||||
uv run manage.py createsuperuser
|
||||
uv run manage.py collectstatic
|
||||
|
||||
# Testing and quality
|
||||
uv run manage.py test
|
||||
uv run black . # Format code
|
||||
uv run flake8 . # Lint code
|
||||
uv run isort . # Sort imports
|
||||
```
|
||||
|
||||
### Frontend Development
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Vue.js development
|
||||
pnpm run dev # Start dev server
|
||||
pnpm run build # Production build
|
||||
pnpm run preview # Preview production build
|
||||
pnpm run test:unit # Vitest unit tests
|
||||
pnpm run test:e2e # Playwright E2E tests
|
||||
pnpm run lint # ESLint
|
||||
pnpm run type-check # TypeScript checking
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Required environment variables:
|
||||
|
||||
#### Root `.env`
|
||||
```bash
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Django
|
||||
# Security
|
||||
SECRET_KEY=your-secret-key
|
||||
DEBUG=True
|
||||
|
||||
# API Configuration
|
||||
API_BASE_URL=http://localhost:8000/api
|
||||
```
|
||||
|
||||
#### Backend `.env`
|
||||
```bash
|
||||
# Django Settings
|
||||
DJANGO_SETTINGS_MODULE=config.django.local
|
||||
DEBUG=True
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379
|
||||
@@ -90,140 +203,142 @@ REDIS_URL=redis://localhost:6379
|
||||
EMAIL_HOST=smtp.gmail.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USE_TLS=True
|
||||
EMAIL_HOST_USER=your-email@gmail.com
|
||||
EMAIL_HOST_PASSWORD=your-app-password
|
||||
```
|
||||
|
||||
### Settings Structure
|
||||
|
||||
- `config/django/base.py` - Base settings
|
||||
- `config/django/local.py` - Development settings
|
||||
- `config/django/production.py` - Production settings
|
||||
- `config/django/test.py` - Test settings
|
||||
|
||||
## 📁 Apps Overview
|
||||
|
||||
### Core Apps
|
||||
|
||||
- **accounts** - User authentication and profile management
|
||||
- **parks** - Theme park models and operations
|
||||
- **rides** - Ride information and relationships
|
||||
- **core** - Shared utilities and base classes
|
||||
|
||||
### Support Apps
|
||||
|
||||
- **moderation** - Content moderation workflows
|
||||
- **location** - Geographic data and services
|
||||
- **media** - File upload and management
|
||||
- **email_service** - Email sending and templates
|
||||
|
||||
## 🔌 API Endpoints
|
||||
|
||||
Base URL: `http://localhost:8000/api/`
|
||||
|
||||
### Authentication
|
||||
- `POST /auth/login/` - User login
|
||||
- `POST /auth/logout/` - User logout
|
||||
- `POST /auth/register/` - User registration
|
||||
|
||||
### Parks
|
||||
- `GET /parks/` - List parks
|
||||
- `GET /parks/{id}/` - Park details
|
||||
- `POST /parks/` - Create park (admin)
|
||||
|
||||
### Rides
|
||||
- `GET /rides/` - List rides
|
||||
- `GET /rides/{id}/` - Ride details
|
||||
- `GET /parks/{park_id}/rides/` - Rides by park
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
#### Frontend `.env.local`
|
||||
```bash
|
||||
# Run all tests
|
||||
uv run manage.py test
|
||||
# API Configuration
|
||||
VITE_API_BASE_URL=http://localhost:8000/api
|
||||
|
||||
# Run specific app tests
|
||||
uv run manage.py test apps.parks
|
||||
# Development
|
||||
VITE_APP_TITLE=ThrillWiki (Development)
|
||||
|
||||
# Run with coverage
|
||||
uv run coverage run manage.py test
|
||||
uv run coverage report
|
||||
# Feature Flags
|
||||
VITE_ENABLE_DEBUG=true
|
||||
```
|
||||
|
||||
## 🔧 Management Commands
|
||||
## 📊 Key Features
|
||||
|
||||
Custom management commands:
|
||||
### Backend Features
|
||||
- **Comprehensive Park Database** - Detailed information about theme parks worldwide
|
||||
- **Extensive Ride Database** - Complete roller coaster and ride information
|
||||
- **User Management** - Authentication, profiles, and permissions
|
||||
- **Content Moderation** - Review and approval workflows
|
||||
- **API Documentation** - Auto-generated OpenAPI/Swagger docs
|
||||
- **Background Tasks** - Celery integration for long-running processes
|
||||
- **Caching Strategy** - Redis-based caching for performance
|
||||
- **Search Functionality** - Full-text search across all content
|
||||
|
||||
```bash
|
||||
# Import park data
|
||||
uv run manage.py import_parks data/parks.json
|
||||
### Frontend Features
|
||||
- **Responsive Design** - Mobile-first approach with Tailwind CSS
|
||||
- **Dark Mode Support** - Complete dark/light theme system
|
||||
- **Real-time Search** - Instant search with debouncing and highlighting
|
||||
- **Interactive Maps** - Park and ride location visualization
|
||||
- **Photo Galleries** - High-quality image management
|
||||
- **User Dashboard** - Personalized content and contributions
|
||||
- **Progressive Web App** - PWA capabilities for mobile experience
|
||||
- **Accessibility** - WCAG 2.1 AA compliance
|
||||
|
||||
# Generate test data
|
||||
uv run manage.py generate_test_data
|
||||
## 📖 Documentation
|
||||
|
||||
# Clean up expired sessions
|
||||
uv run manage.py clearsessions
|
||||
```
|
||||
### Core Documentation
|
||||
- **[Backend Documentation](./backend/README.md)** - Django setup and API details
|
||||
- **[Frontend Documentation](./frontend/README.md)** - Vue.js setup and development
|
||||
- **[API Documentation](./shared/docs/api/README.md)** - Complete API reference
|
||||
- **[Development Workflow](./shared/docs/development/workflow.md)** - Daily development processes
|
||||
|
||||
## 📊 Database
|
||||
### Architecture & Deployment
|
||||
- **[Architecture Overview](./architecture/)** - System design and decisions
|
||||
- **[Deployment Guide](./shared/docs/deployment/)** - Production deployment instructions
|
||||
- **[Development Scripts](./shared/scripts/)** - Automation and tooling
|
||||
|
||||
### Entity Relationships
|
||||
|
||||
- **Parks** have Operators (required) and PropertyOwners (optional)
|
||||
- **Rides** belong to Parks and may have Manufacturers/Designers
|
||||
- **Users** can create submissions and moderate content
|
||||
|
||||
### Migrations
|
||||
|
||||
```bash
|
||||
# Create migrations
|
||||
uv run manage.py makemigrations
|
||||
|
||||
# Apply migrations
|
||||
uv run manage.py migrate
|
||||
|
||||
# Show migration status
|
||||
uv run manage.py showmigrations
|
||||
```
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
- CORS configured for frontend integration
|
||||
- CSRF protection enabled
|
||||
- JWT token authentication
|
||||
- Rate limiting on API endpoints
|
||||
- Input validation and sanitization
|
||||
|
||||
## 📈 Performance
|
||||
|
||||
- Database query optimization
|
||||
- Redis caching for frequent queries
|
||||
- Background task processing with Celery
|
||||
- Database connection pooling
|
||||
### Additional Resources
|
||||
- **[Contributing Guide](./CONTRIBUTING.md)** - How to contribute to the project
|
||||
- **[Code of Conduct](./CODE_OF_CONDUCT.md)** - Community guidelines
|
||||
- **[Security Policy](./SECURITY.md)** - Security reporting and policies
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
See the [Deployment Guide](../shared/docs/deployment/) for production setup.
|
||||
### Development Environment
|
||||
```bash
|
||||
# Quick start with all services
|
||||
./shared/scripts/dev/start-all.sh
|
||||
|
||||
## 🐛 Debugging
|
||||
# Full development setup
|
||||
./shared/scripts/dev/setup-dev.sh
|
||||
```
|
||||
|
||||
### Development Tools
|
||||
### Production Deployment
|
||||
```bash
|
||||
# Build all components
|
||||
./shared/scripts/build/build-all.sh
|
||||
|
||||
- Django Debug Toolbar
|
||||
- Django Extensions
|
||||
- Silk profiler for performance analysis
|
||||
# Deploy to production
|
||||
./shared/scripts/deploy/deploy.sh
|
||||
```
|
||||
|
||||
### Logging
|
||||
See [Deployment Guide](./shared/docs/deployment/) for detailed production setup instructions.
|
||||
|
||||
Logs are written to:
|
||||
- Console (development)
|
||||
- Files in `logs/` directory (production)
|
||||
- External logging service (production)
|
||||
## 🧪 Testing Strategy
|
||||
|
||||
### Backend Testing
|
||||
- **Unit Tests** - Individual function and method testing
|
||||
- **Integration Tests** - API endpoint and database interaction testing
|
||||
- **E2E Tests** - Full user journey testing with Selenium
|
||||
|
||||
### Frontend Testing
|
||||
- **Unit Tests** - Component and utility function testing with Vitest
|
||||
- **Integration Tests** - Component interaction testing
|
||||
- **E2E Tests** - User journey testing with Playwright
|
||||
|
||||
### Code Quality
|
||||
- **Linting** - ESLint for JavaScript/TypeScript, Flake8 for Python
|
||||
- **Type Checking** - TypeScript for frontend, mypy for Python
|
||||
- **Code Formatting** - Prettier for frontend, Black for Python
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Follow Django coding standards
|
||||
2. Write tests for new features
|
||||
3. Update documentation
|
||||
4. Run linting: `uv run flake8 .`
|
||||
5. Format code: `uv run black .`
|
||||
We welcome contributions! Please see our [Contributing Guide](./CONTRIBUTING.md) for details on:
|
||||
|
||||
1. **Development Setup** - Getting your development environment ready
|
||||
2. **Code Standards** - Coding conventions and best practices
|
||||
3. **Pull Request Process** - How to submit your changes
|
||||
4. **Issue Reporting** - How to report bugs and request features
|
||||
|
||||
### Quick Contribution Start
|
||||
```bash
|
||||
# Fork and clone the repository
|
||||
git clone https://github.com/your-username/thrillwiki-monorepo.git
|
||||
cd thrillwiki-monorepo
|
||||
|
||||
# Set up development environment
|
||||
./shared/scripts/dev/setup-dev.sh
|
||||
|
||||
# Create a feature branch
|
||||
git checkout -b feature/your-feature-name
|
||||
|
||||
# Make your changes and test
|
||||
pnpm run test
|
||||
|
||||
# Submit a pull request
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- **Theme Park Community** - For providing data and inspiration
|
||||
- **Open Source Contributors** - For the amazing tools and libraries
|
||||
- **Vue.js and Django Communities** - For excellent documentation and support
|
||||
|
||||
## 📞 Support
|
||||
|
||||
- **Issues** - [GitHub Issues](https://github.com/your-repo/thrillwiki-monorepo/issues)
|
||||
- **Discussions** - [GitHub Discussions](https://github.com/your-repo/thrillwiki-monorepo/discussions)
|
||||
- **Documentation** - [Project Wiki](https://github.com/your-repo/thrillwiki-monorepo/wiki)
|
||||
|
||||
---
|
||||
|
||||
**Built with ❤️ for the theme park and roller coaster community**
|
||||
|
||||
180
README_HYBRID_ENDPOINTS.md
Normal file
180
README_HYBRID_ENDPOINTS.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# ThrillWiki Hybrid Filtering Endpoints Test Suite
|
||||
|
||||
This repository contains a comprehensive test script for the newly synchronized Parks and Rides hybrid filtering endpoints.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Start the Django server:**
|
||||
```bash
|
||||
cd backend && uv run manage.py runserver 8000
|
||||
```
|
||||
|
||||
2. **Run the test script:**
|
||||
```bash
|
||||
./test_hybrid_endpoints.sh
|
||||
```
|
||||
|
||||
Or with a custom base URL:
|
||||
```bash
|
||||
./test_hybrid_endpoints.sh http://localhost:8000
|
||||
```
|
||||
|
||||
## What Gets Tested
|
||||
|
||||
### Parks Hybrid Filtering (`/api/v1/parks/hybrid/`)
|
||||
- ✅ Basic hybrid filtering (automatic strategy selection)
|
||||
- ✅ Search functionality (`?search=disney`)
|
||||
- ✅ Status filtering (`?status=OPERATING,CLOSED_TEMP`)
|
||||
- ✅ Geographic filtering (`?country=United%20States&state=Florida,California`)
|
||||
- ✅ Numeric range filtering (`?opening_year_min=1990&rating_min=4.0`)
|
||||
- ✅ Park statistics filtering (`?size_min=100&ride_count_min=10`)
|
||||
- ✅ Operator filtering (`?operator=disney,universal`)
|
||||
- ✅ Progressive loading (`?offset=50`)
|
||||
- ✅ Filter metadata (`/filter-metadata/`)
|
||||
- ✅ Scoped metadata (`/filter-metadata/?scoped=true&country=United%20States`)
|
||||
|
||||
### Rides Hybrid Filtering (`/api/v1/rides/hybrid/`)
|
||||
- ✅ Basic hybrid filtering (automatic strategy selection)
|
||||
- ✅ Search functionality (`?search=coaster`)
|
||||
- ✅ Category filtering (`?category=RC,DR`)
|
||||
- ✅ Status and park filtering (`?status=OPERATING&park_slug=cedar-point`)
|
||||
- ✅ Manufacturer/designer filtering (`?manufacturer=bolliger-mabillard`)
|
||||
- ✅ Roller coaster specific filtering (`?roller_coaster_type=INVERTED&has_inversions=true`)
|
||||
- ✅ Performance filtering (`?height_ft_min=200&speed_mph_min=70`)
|
||||
- ✅ Quality metrics (`?rating_min=4.5&capacity_min=1000`)
|
||||
- ✅ Accessibility filtering (`?height_requirement_min=48&height_requirement_max=54`)
|
||||
- ✅ Progressive loading (`?offset=25&category=RC`)
|
||||
- ✅ Filter metadata (`/filter-metadata/`)
|
||||
- ✅ Scoped metadata (`/filter-metadata/?scoped=true&category=RC`)
|
||||
|
||||
### Advanced Testing
|
||||
- ✅ Complex combination queries
|
||||
- ✅ Edge cases (empty results, invalid parameters)
|
||||
- ✅ Performance timing comparisons
|
||||
- ✅ Error handling validation
|
||||
|
||||
## Key Features Demonstrated
|
||||
|
||||
### 🔄 Automatic Strategy Selection
|
||||
- **≤200 records**: Client-side filtering (loads all data for frontend filtering)
|
||||
- **>200 records**: Server-side filtering (database filtering with pagination)
|
||||
|
||||
### 📊 Progressive Loading
|
||||
- Initial load: 50 records
|
||||
- Progressive batches: 25 records
|
||||
- Seamless pagination for large datasets
|
||||
|
||||
### 🔍 Comprehensive Filtering
|
||||
- **Parks**: 17+ filter parameters including geographic, temporal, and statistical filters
|
||||
- **Rides**: 17+ filter parameters including roller coaster specs, performance metrics, and accessibility
|
||||
|
||||
### 📋 Dynamic Filter Metadata
|
||||
- Real-time filter options based on current data
|
||||
- Scoped metadata for contextual filtering
|
||||
- Ranges and categorical options automatically generated
|
||||
|
||||
### ⚡ Performance Optimized
|
||||
- 5-minute intelligent caching
|
||||
- Strategic database indexing
|
||||
- Optimized queries with prefetch_related
|
||||
|
||||
## Response Format
|
||||
|
||||
Both endpoints return consistent response structures:
|
||||
|
||||
```json
|
||||
{
|
||||
"parks": [...], // or "rides": [...]
|
||||
"total_count": 123,
|
||||
"strategy": "client_side", // or "server_side"
|
||||
"has_more": false,
|
||||
"next_offset": null,
|
||||
"filter_metadata": {
|
||||
"categorical": {
|
||||
"countries": ["United States", "Canada", ...],
|
||||
"categories": ["RC", "DR", "FR", ...],
|
||||
// ... more options
|
||||
},
|
||||
"ranges": {
|
||||
"opening_year": {"min": 1800, "max": 2025},
|
||||
"rating": {"min": 1.0, "max": 10.0},
|
||||
// ... more ranges
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **curl**: Required for making HTTP requests
|
||||
- **jq**: Optional but recommended for pretty JSON formatting
|
||||
|
||||
## Example Usage
|
||||
|
||||
### Basic Parks Query
|
||||
```bash
|
||||
curl "http://localhost:8000/api/v1/parks/hybrid/"
|
||||
```
|
||||
|
||||
### Search for Disney Parks
|
||||
```bash
|
||||
curl "http://localhost:8000/api/v1/parks/hybrid/?search=disney"
|
||||
```
|
||||
|
||||
### Filter Roller Coasters with Inversions
|
||||
```bash
|
||||
curl "http://localhost:8000/api/v1/rides/hybrid/?category=RC&has_inversions=true&height_ft_min=100"
|
||||
```
|
||||
|
||||
### Get Filter Metadata
|
||||
```bash
|
||||
curl "http://localhost:8000/api/v1/parks/hybrid/filter-metadata/"
|
||||
```
|
||||
|
||||
## Integration Guide
|
||||
|
||||
### Frontend Integration
|
||||
1. Use filter metadata to build dynamic filter interfaces
|
||||
2. Implement progressive loading for better UX
|
||||
3. Handle both client-side and server-side strategies
|
||||
4. Cache filter metadata to reduce API calls
|
||||
|
||||
### Performance Considerations
|
||||
- Monitor response times and adjust thresholds as needed
|
||||
- Use progressive loading for datasets >200 records
|
||||
- Implement proper error handling for edge cases
|
||||
- Consider implementing request debouncing for search
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server Not Running
|
||||
```
|
||||
❌ Server not available at http://localhost:8000
|
||||
💡 Make sure to start the Django server first:
|
||||
cd backend && uv run manage.py runserver 8000
|
||||
```
|
||||
|
||||
### Missing jq
|
||||
```
|
||||
⚠️ jq not found - JSON responses will not be pretty-printed
|
||||
```
|
||||
Install jq for better output formatting:
|
||||
```bash
|
||||
# macOS
|
||||
brew install jq
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install jq
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Integrate into Frontend**: Use these endpoints in your React/Next.js application
|
||||
2. **Build Filter UI**: Create dynamic filter interfaces using the metadata
|
||||
3. **Implement Progressive Loading**: Handle large datasets efficiently
|
||||
4. **Monitor Performance**: Track response times and optimize as needed
|
||||
5. **Add Caching**: Implement client-side caching for better UX
|
||||
|
||||
---
|
||||
|
||||
🎢 **Happy filtering!** These endpoints provide a powerful, scalable foundation for building advanced search and filtering experiences in your theme park application.
|
||||
326
TAILWIND_V4_MIGRATION.md
Normal file
326
TAILWIND_V4_MIGRATION.md
Normal file
@@ -0,0 +1,326 @@
|
||||
# Tailwind CSS v3 to v4 Migration Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
This document details the complete migration process from Tailwind CSS v3 to v4 for the Django ThrillWiki project. The migration was performed on August 15, 2025, and includes all changes, configurations, and verification steps.
|
||||
|
||||
## Migration Summary
|
||||
|
||||
- **From**: Tailwind CSS v3.x
|
||||
- **To**: Tailwind CSS v4.1.12
|
||||
- **Project**: Django ThrillWiki (Django + Tailwind CSS integration)
|
||||
- **Status**: ✅ Complete and Verified
|
||||
- **Breaking Changes**: None (all styling preserved)
|
||||
|
||||
## Key Changes in Tailwind CSS v4
|
||||
|
||||
### 1. CSS Import Syntax
|
||||
- **v3**: Used `@tailwind` directives
|
||||
- **v4**: Uses single `@import "tailwindcss"` statement
|
||||
|
||||
### 2. Theme Configuration
|
||||
- **v3**: Configuration in `tailwind.config.js`
|
||||
- **v4**: CSS-first approach with `@theme` blocks
|
||||
|
||||
### 3. Deprecated Utilities
|
||||
Multiple utility classes were renamed or deprecated in v4.
|
||||
|
||||
## Migration Steps Performed
|
||||
|
||||
### Step 1: Update Main CSS File
|
||||
|
||||
**File**: `static/css/src/input.css`
|
||||
|
||||
**Before (v3)**:
|
||||
```css
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom styles... */
|
||||
```
|
||||
|
||||
**After (v4)**:
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-primary: #4f46e5;
|
||||
--color-secondary: #e11d48;
|
||||
--color-accent: #8b5cf6;
|
||||
--font-family-sans: Poppins, sans-serif;
|
||||
}
|
||||
|
||||
/* Custom styles... */
|
||||
```
|
||||
|
||||
### Step 2: Theme Variable Migration
|
||||
|
||||
Migrated custom colors and fonts from `tailwind.config.js` to CSS variables in `@theme` block:
|
||||
|
||||
| Variable | Value | Description |
|
||||
|----------|-------|-------------|
|
||||
| `--color-primary` | `#4f46e5` | Indigo-600 (primary brand color) |
|
||||
| `--color-secondary` | `#e11d48` | Rose-600 (secondary brand color) |
|
||||
| `--color-accent` | `#8b5cf6` | Violet-500 (accent color) |
|
||||
| `--font-family-sans` | `Poppins, sans-serif` | Primary font family |
|
||||
|
||||
### Step 3: Deprecated Utility Updates
|
||||
|
||||
#### Outline Utilities
|
||||
- **Changed**: `outline-none` → `outline-hidden`
|
||||
- **Files affected**: All template files, component CSS
|
||||
|
||||
#### Ring Utilities
|
||||
- **Changed**: `ring` → `ring-3`
|
||||
- **Reason**: Default ring width now requires explicit specification
|
||||
|
||||
#### Shadow Utilities
|
||||
- **Changed**:
|
||||
- `shadow-sm` → `shadow-xs`
|
||||
- `shadow` → `shadow-sm`
|
||||
- **Files affected**: Button components, card components
|
||||
|
||||
#### Opacity Utilities
|
||||
- **Changed**: `bg-opacity-*` format → `color/opacity` format
|
||||
- **Example**: `bg-blue-500 bg-opacity-50` → `bg-blue-500/50`
|
||||
|
||||
#### Flex Utilities
|
||||
- **Changed**: `flex-shrink-0` → `shrink-0`
|
||||
|
||||
#### Important Modifier
|
||||
- **Changed**: `!important` → `!` (shorter syntax)
|
||||
- **Example**: `!outline-none` → `!outline-hidden`
|
||||
|
||||
### Step 4: Template File Updates
|
||||
|
||||
Updated the following template files with new utility classes:
|
||||
|
||||
#### Core Templates
|
||||
- `templates/base.html`
|
||||
- `templates/components/navbar.html`
|
||||
- `templates/components/footer.html`
|
||||
|
||||
#### Page Templates
|
||||
- `templates/parks/park_list.html`
|
||||
- `templates/parks/park_detail.html`
|
||||
- `templates/rides/ride_list.html`
|
||||
- `templates/rides/ride_detail.html`
|
||||
- `templates/companies/company_list.html`
|
||||
- `templates/companies/company_detail.html`
|
||||
|
||||
#### Form Templates
|
||||
- `templates/parks/park_form.html`
|
||||
- `templates/rides/ride_form.html`
|
||||
- `templates/companies/company_form.html`
|
||||
|
||||
#### Component Templates
|
||||
- `templates/components/search_results.html`
|
||||
- `templates/components/pagination.html`
|
||||
|
||||
### Step 5: Component CSS Updates
|
||||
|
||||
Updated custom component classes in `static/css/src/input.css`:
|
||||
|
||||
**Button Components**:
|
||||
```css
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center px-6 py-2.5 border border-transparent rounded-full shadow-md text-sm font-medium text-white bg-gradient-to-r from-primary to-secondary hover:from-primary/90 hover:to-secondary/90 focus:outline-hidden focus:ring-3 focus:ring-offset-2 focus:ring-primary/50 transform hover:scale-105 transition-all;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center px-6 py-2.5 border border-gray-200 dark:border-gray-700 rounded-full shadow-md text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-hidden focus:ring-3 focus:ring-offset-2 focus:ring-primary/50 transform hover:scale-105 transition-all;
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### Tailwind Config (Preserved for Reference)
|
||||
|
||||
**File**: `tailwind.config.js`
|
||||
|
||||
The original v3 configuration was preserved for reference but is no longer the primary configuration method:
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
content: [
|
||||
'./templates/**/*.html',
|
||||
'./static/js/**/*.js',
|
||||
'./*/templates/**/*.html',
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#4f46e5',
|
||||
secondary: '#e11d48',
|
||||
accent: '#8b5cf6',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Poppins', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
require('@tailwindcss/typography'),
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Package.json Updates
|
||||
|
||||
No changes required to `package.json` as the Django-Tailwind package handles version management.
|
||||
|
||||
## Verification Steps
|
||||
|
||||
### 1. Build Process Verification
|
||||
```bash
|
||||
# Clean and rebuild CSS
|
||||
lsof -ti :8000 | xargs kill -9
|
||||
find . -type d -name "__pycache__" -exec rm -r {} +
|
||||
uv run manage.py tailwind runserver
|
||||
```
|
||||
|
||||
**Result**: ✅ Build successful, no errors
|
||||
|
||||
### 2. CSS Compilation Check
|
||||
```bash
|
||||
# Check compiled CSS size and content
|
||||
ls -la static/css/tailwind.css
|
||||
head -50 static/css/tailwind.css | grep -E "(primary|secondary|accent)"
|
||||
```
|
||||
|
||||
**Result**: ✅ CSS properly compiled with theme variables
|
||||
|
||||
### 3. Server Response Check
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/
|
||||
```
|
||||
|
||||
**Result**: ✅ HTTP 200 - Server responding correctly
|
||||
|
||||
### 4. Visual Verification
|
||||
- ✅ Primary colors (indigo) displaying correctly
|
||||
- ✅ Secondary colors (rose) displaying correctly
|
||||
- ✅ Accent colors (violet) displaying correctly
|
||||
- ✅ Poppins font family loading correctly
|
||||
- ✅ Button styling and interactions working
|
||||
- ✅ Dark mode functionality preserved
|
||||
- ✅ Responsive design intact
|
||||
- ✅ All animations and transitions working
|
||||
|
||||
## Files Modified
|
||||
|
||||
### CSS Files
|
||||
- `static/css/src/input.css` - ✅ Major updates (import syntax, theme variables, component classes)
|
||||
|
||||
### Template Files (Updated utility classes)
|
||||
- `templates/base.html`
|
||||
- `templates/components/navbar.html`
|
||||
- `templates/components/footer.html`
|
||||
- `templates/parks/park_list.html`
|
||||
- `templates/parks/park_detail.html`
|
||||
- `templates/parks/park_form.html`
|
||||
- `templates/rides/ride_list.html`
|
||||
- `templates/rides/ride_detail.html`
|
||||
- `templates/rides/ride_form.html`
|
||||
- `templates/companies/company_list.html`
|
||||
- `templates/companies/company_detail.html`
|
||||
- `templates/companies/company_form.html`
|
||||
- `templates/components/search_results.html`
|
||||
- `templates/components/pagination.html`
|
||||
|
||||
### Configuration Files (Preserved)
|
||||
- `tailwind.config.js` - ✅ Preserved for reference
|
||||
|
||||
## Benefits of v4 Migration
|
||||
|
||||
### Performance Improvements
|
||||
- Smaller CSS bundle size
|
||||
- Faster compilation times
|
||||
- Improved CSS-in-JS performance
|
||||
|
||||
### Developer Experience
|
||||
- CSS-first configuration approach
|
||||
- Better IDE support for theme variables
|
||||
- Simplified import syntax
|
||||
|
||||
### Future Compatibility
|
||||
- Modern CSS features support
|
||||
- Better container queries support
|
||||
- Enhanced dark mode capabilities
|
||||
|
||||
## Troubleshooting Guide
|
||||
|
||||
### Common Issues and Solutions
|
||||
|
||||
#### Issue: "Cannot apply unknown utility class"
|
||||
**Solution**: Check if utility was renamed in v4 migration table above
|
||||
|
||||
#### Issue: Custom colors not working
|
||||
**Solution**: Ensure `@theme` block is properly defined with CSS variables
|
||||
|
||||
#### Issue: Build errors
|
||||
**Solution**: Run clean build process:
|
||||
```bash
|
||||
lsof -ti :8000 | xargs kill -9
|
||||
find . -type d -name "__pycache__" -exec rm -r {} +
|
||||
uv run manage.py tailwind runserver
|
||||
```
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If rollback is needed:
|
||||
|
||||
1. **Restore CSS Import Syntax**:
|
||||
```css
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
```
|
||||
|
||||
2. **Remove @theme Block**: Delete the `@theme` section from input.css
|
||||
|
||||
3. **Revert Utility Classes**: Use search/replace to revert utility class changes
|
||||
|
||||
4. **Downgrade Tailwind**: Update package to v3.x version
|
||||
|
||||
## Post-Migration Checklist
|
||||
|
||||
- [x] CSS compilation working
|
||||
- [x] Development server running
|
||||
- [x] All pages loading correctly
|
||||
- [x] Colors displaying properly
|
||||
- [x] Fonts loading correctly
|
||||
- [x] Interactive elements working
|
||||
- [x] Dark mode functioning
|
||||
- [x] Responsive design intact
|
||||
- [x] No console errors
|
||||
- [x] Performance acceptable
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### New v4 Features to Explore
|
||||
- Enhanced container queries
|
||||
- Improved dark mode utilities
|
||||
- New color-mix() support
|
||||
- Advanced CSS nesting
|
||||
|
||||
### Maintenance Notes
|
||||
- Monitor for v4 updates and new features
|
||||
- Consider migrating more configuration to CSS variables
|
||||
- Evaluate new utility classes as they're released
|
||||
|
||||
## Contact and Support
|
||||
|
||||
For questions about this migration:
|
||||
- Review this documentation
|
||||
- Check Tailwind CSS v4 official documentation
|
||||
- Consult the preserved `tailwind.config.js` for original settings
|
||||
|
||||
---
|
||||
|
||||
**Migration Completed**: August 15, 2025
|
||||
**Tailwind Version**: v4.1.12
|
||||
**Status**: Production Ready ✅
|
||||
80
TAILWIND_V4_QUICK_REFERENCE.md
Normal file
80
TAILWIND_V4_QUICK_REFERENCE.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Tailwind CSS v4 Quick Reference Guide
|
||||
|
||||
## Common v3 → v4 Utility Migrations
|
||||
|
||||
| v3 Utility | v4 Utility | Notes |
|
||||
|------------|------------|-------|
|
||||
| `outline-none` | `outline-hidden` | Accessibility improvement |
|
||||
| `ring` | `ring-3` | Must specify ring width |
|
||||
| `shadow-sm` | `shadow-xs` | Renamed for consistency |
|
||||
| `shadow` | `shadow-sm` | Renamed for consistency |
|
||||
| `flex-shrink-0` | `shrink-0` | Shortened syntax |
|
||||
| `bg-blue-500 bg-opacity-50` | `bg-blue-500/50` | New opacity syntax |
|
||||
| `text-gray-700 text-opacity-75` | `text-gray-700/75` | New opacity syntax |
|
||||
| `!outline-none` | `!outline-hidden` | Updated important syntax |
|
||||
|
||||
## Theme Variables (Available in CSS)
|
||||
|
||||
```css
|
||||
/* Colors */
|
||||
var(--color-primary) /* #4f46e5 - Indigo-600 */
|
||||
var(--color-secondary) /* #e11d48 - Rose-600 */
|
||||
var(--color-accent) /* #8b5cf6 - Violet-500 */
|
||||
|
||||
/* Fonts */
|
||||
var(--font-family-sans) /* Poppins, sans-serif */
|
||||
```
|
||||
|
||||
## Usage in Templates
|
||||
|
||||
### Before (v3)
|
||||
```html
|
||||
<button class="outline-none ring hover:ring-2 shadow-sm bg-blue-500 bg-opacity-75">
|
||||
Click me
|
||||
</button>
|
||||
```
|
||||
|
||||
### After (v4)
|
||||
```html
|
||||
<button class="outline-hidden ring-3 hover:ring-2 shadow-xs bg-blue-500/75">
|
||||
Click me
|
||||
</button>
|
||||
```
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Start Development Server
|
||||
```bash
|
||||
lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; uv run manage.py tailwind runserver
|
||||
```
|
||||
|
||||
### Force CSS Rebuild
|
||||
```bash
|
||||
uv run manage.py tailwind build
|
||||
```
|
||||
|
||||
## New v4 Features
|
||||
|
||||
- **CSS-first configuration** via `@theme` blocks
|
||||
- **Improved opacity syntax** with `/` operator
|
||||
- **Better color-mix() support**
|
||||
- **Enhanced dark mode utilities**
|
||||
- **Faster compilation**
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Unknown utility class error
|
||||
1. Check if utility was renamed (see table above)
|
||||
2. Verify custom theme variables are defined
|
||||
3. Run clean build process
|
||||
|
||||
### Colors not working
|
||||
1. Ensure `@theme` block exists in `static/css/src/input.css`
|
||||
2. Check CSS variable names match usage
|
||||
3. Verify CSS compilation completed
|
||||
|
||||
## Resources
|
||||
|
||||
- [Full Migration Documentation](./TAILWIND_V4_MIGRATION.md)
|
||||
- [Tailwind CSS v4 Official Docs](https://tailwindcss.com/docs)
|
||||
- [Django-Tailwind Package](https://django-tailwind.readthedocs.io/)
|
||||
470
THRILLWIKI_API_DOCUMENTATION.md
Normal file
470
THRILLWIKI_API_DOCUMENTATION.md
Normal file
@@ -0,0 +1,470 @@
|
||||
# ThrillWiki API Documentation v1
|
||||
## Complete Frontend Developer Reference
|
||||
|
||||
**Base URL**: `/api/v1/`
|
||||
**Authentication**: JWT Bearer tokens
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Authentication Endpoints (`/api/v1/auth/`)
|
||||
|
||||
### Core Authentication
|
||||
- **POST** `/auth/login/` - User login with username/email and password
|
||||
- **POST** `/auth/signup/` - User registration (email verification required)
|
||||
- **POST** `/auth/logout/` - Logout current user (blacklist refresh token)
|
||||
- **GET** `/auth/user/` - Get current authenticated user information
|
||||
- **POST** `/auth/status/` - Check authentication status
|
||||
|
||||
### Password Management
|
||||
- **POST** `/auth/password/reset/` - Request password reset email
|
||||
- **POST** `/auth/password/change/` - Change current user's password
|
||||
|
||||
### Email Verification
|
||||
- **GET** `/auth/verify-email/<token>/` - Verify email with token
|
||||
- **POST** `/auth/resend-verification/` - Resend email verification
|
||||
|
||||
### Social Authentication
|
||||
- **GET** `/auth/social/providers/` - Get available social auth providers
|
||||
- **GET** `/auth/social/providers/available/` - Get available social providers list
|
||||
- **GET** `/auth/social/connected/` - Get user's connected social providers
|
||||
- **POST** `/auth/social/connect/<provider>/` - Connect social provider (Google, Discord)
|
||||
- **POST** `/auth/social/disconnect/<provider>/` - Disconnect social provider
|
||||
- **GET** `/auth/social/status/` - Get comprehensive social auth status
|
||||
- **POST** `/auth/social/` - Social auth endpoints (dj-rest-auth)
|
||||
|
||||
### JWT Token Management
|
||||
- **POST** `/auth/token/refresh/` - Refresh JWT access token
|
||||
|
||||
---
|
||||
|
||||
## 🏞️ Parks API Endpoints (`/api/v1/parks/`)
|
||||
|
||||
### Core CRUD Operations
|
||||
- **GET** `/parks/` - List parks with comprehensive filtering and pagination
|
||||
- **POST** `/parks/` - Create new park (authenticated users)
|
||||
- **GET** `/parks/<pk>/` - Get park details (supports ID or slug)
|
||||
- **PATCH** `/parks/<pk>/` - Update park (partial update)
|
||||
- **PUT** `/parks/<pk>/` - Update park (full update)
|
||||
- **DELETE** `/parks/<pk>/` - Delete park
|
||||
|
||||
### Filtering & Search
|
||||
- **GET** `/parks/filter-options/` - Get available filter options
|
||||
- **GET** `/parks/search/companies/?q=<query>` - Search companies/operators
|
||||
- **GET** `/parks/search-suggestions/?q=<query>` - Get park search suggestions
|
||||
- **GET** `/parks/hybrid/` - Hybrid park filtering with advanced options
|
||||
- **GET** `/parks/hybrid/filter-metadata/` - Get filter metadata for hybrid filtering
|
||||
|
||||
### Park Photos Management
|
||||
- **GET** `/parks/<park_pk>/photos/` - List park photos
|
||||
- **POST** `/parks/<park_pk>/photos/` - Upload park photo
|
||||
- **GET** `/parks/<park_pk>/photos/<id>/` - Get park photo details
|
||||
- **PATCH** `/parks/<park_pk>/photos/<id>/` - Update park photo
|
||||
- **DELETE** `/parks/<park_pk>/photos/<id>/` - Delete park photo
|
||||
- **POST** `/parks/<park_pk>/photos/<id>/set_primary/` - Set photo as primary
|
||||
- **POST** `/parks/<park_pk>/photos/bulk_approve/` - Bulk approve/reject photos (admin)
|
||||
- **GET** `/parks/<park_pk>/photos/stats/` - Get park photo statistics
|
||||
|
||||
### Park Settings
|
||||
- **GET** `/parks/<pk>/image-settings/` - Get park image settings
|
||||
- **POST** `/parks/<pk>/image-settings/` - Update park image settings
|
||||
|
||||
#### Park Filtering Parameters (24 total):
|
||||
- **Pagination**: `page`, `page_size`
|
||||
- **Search**: `search`
|
||||
- **Location**: `continent`, `country`, `state`, `city`
|
||||
- **Attributes**: `park_type`, `status`
|
||||
- **Companies**: `operator_id`, `operator_slug`, `property_owner_id`, `property_owner_slug`
|
||||
- **Ratings**: `min_rating`, `max_rating`
|
||||
- **Ride Counts**: `min_ride_count`, `max_ride_count`
|
||||
- **Opening Year**: `opening_year`, `min_opening_year`, `max_opening_year`
|
||||
- **Roller Coasters**: `has_roller_coasters`, `min_roller_coaster_count`, `max_roller_coaster_count`
|
||||
- **Ordering**: `ordering`
|
||||
|
||||
---
|
||||
|
||||
## 🎢 Rides API Endpoints (`/api/v1/rides/`)
|
||||
|
||||
### Core CRUD Operations
|
||||
- **GET** `/rides/` - List rides with comprehensive filtering
|
||||
- **POST** `/rides/` - Create new ride
|
||||
- **GET** `/rides/<pk>/` - Get ride details
|
||||
- **PATCH** `/rides/<pk>/` - Update ride (partial)
|
||||
- **PUT** `/rides/<pk>/` - Update ride (full)
|
||||
- **DELETE** `/rides/<pk>/` - Delete ride
|
||||
|
||||
### Filtering & Search
|
||||
- **GET** `/rides/filter-options/` - Get available filter options
|
||||
- **GET** `/rides/search/companies/?q=<query>` - Search ride companies
|
||||
- **GET** `/rides/search/ride-models/?q=<query>` - Search ride models
|
||||
- **GET** `/rides/search-suggestions/?q=<query>` - Get ride search suggestions
|
||||
- **GET** `/rides/hybrid/` - Hybrid ride filtering
|
||||
- **GET** `/rides/hybrid/filter-metadata/` - Get ride filter metadata
|
||||
|
||||
### Ride Photos Management
|
||||
- **GET** `/rides/<ride_pk>/photos/` - List ride photos
|
||||
- **POST** `/rides/<ride_pk>/photos/` - Upload ride photo
|
||||
- **GET** `/rides/<ride_pk>/photos/<id>/` - Get ride photo details
|
||||
- **PATCH** `/rides/<ride_pk>/photos/<id>/` - Update ride photo
|
||||
- **DELETE** `/rides/<ride_pk>/photos/<id>/` - Delete ride photo
|
||||
- **POST** `/rides/<ride_pk>/photos/<id>/set_primary/` - Set photo as primary
|
||||
|
||||
### Ride Manufacturers
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/` - Manufacturer-specific endpoints
|
||||
|
||||
### Ride Settings
|
||||
- **GET** `/rides/<pk>/image-settings/` - Get ride image settings
|
||||
- **POST** `/rides/<pk>/image-settings/` - Update ride image settings
|
||||
|
||||
---
|
||||
|
||||
## 👤 User Accounts API (`/api/v1/accounts/`)
|
||||
|
||||
### User Management (Admin)
|
||||
- **DELETE** `/accounts/users/<user_id>/delete/` - Delete user while preserving submissions
|
||||
- **GET** `/accounts/users/<user_id>/deletion-check/` - Check user deletion eligibility
|
||||
|
||||
### Self-Service Account Management
|
||||
- **POST** `/accounts/delete-account/request/` - Request account deletion
|
||||
- **POST** `/accounts/delete-account/verify/` - Verify account deletion
|
||||
- **POST** `/accounts/delete-account/cancel/` - Cancel account deletion
|
||||
|
||||
### User Profile Management
|
||||
- **GET** `/accounts/profile/` - Get user profile
|
||||
- **PATCH** `/accounts/profile/account/` - Update user account info
|
||||
- **PATCH** `/accounts/profile/update/` - Update user profile
|
||||
|
||||
### User Preferences
|
||||
- **GET** `/accounts/preferences/` - Get user preferences
|
||||
- **PATCH** `/accounts/preferences/update/` - Update user preferences
|
||||
- **PATCH** `/accounts/preferences/theme/` - Update theme preference
|
||||
|
||||
### Settings Management
|
||||
- **GET** `/accounts/settings/notifications/` - Get notification settings
|
||||
- **PATCH** `/accounts/settings/notifications/update/` - Update notification settings
|
||||
- **GET** `/accounts/settings/privacy/` - Get privacy settings
|
||||
- **PATCH** `/accounts/settings/privacy/update/` - Update privacy settings
|
||||
- **GET** `/accounts/settings/security/` - Get security settings
|
||||
- **PATCH** `/accounts/settings/security/update/` - Update security settings
|
||||
|
||||
### User Statistics & Lists
|
||||
- **GET** `/accounts/statistics/` - Get user statistics
|
||||
- **GET** `/accounts/top-lists/` - Get user's top lists
|
||||
- **POST** `/accounts/top-lists/create/` - Create new top list
|
||||
- **PATCH** `/accounts/top-lists/<list_id>/` - Update top list
|
||||
- **DELETE** `/accounts/top-lists/<list_id>/delete/` - Delete top list
|
||||
|
||||
### Notifications
|
||||
- **GET** `/accounts/notifications/` - Get user notifications
|
||||
- **POST** `/accounts/notifications/mark-read/` - Mark notifications as read
|
||||
- **GET** `/accounts/notification-preferences/` - Get notification preferences
|
||||
- **PATCH** `/accounts/notification-preferences/update/` - Update notification preferences
|
||||
|
||||
### Avatar Management
|
||||
- **POST** `/accounts/profile/avatar/upload/` - Upload avatar
|
||||
- **POST** `/accounts/profile/avatar/save/` - Save avatar image
|
||||
- **DELETE** `/accounts/profile/avatar/delete/` - Delete avatar
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Maps API (`/api/v1/maps/`)
|
||||
|
||||
### Location Data
|
||||
- **GET** `/maps/locations/` - Get map locations data
|
||||
- **GET** `/maps/locations/<location_type>/<location_id>/` - Get location details
|
||||
- **GET** `/maps/search/` - Search locations on map
|
||||
- **GET** `/maps/bounds/` - Query locations within bounds
|
||||
|
||||
### Map Services
|
||||
- **GET** `/maps/stats/` - Get map service statistics
|
||||
- **GET** `/maps/cache/` - Get map cache information
|
||||
- **POST** `/maps/cache/invalidate/` - Invalidate map cache
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Core Search API (`/api/v1/core/`)
|
||||
|
||||
### Entity Search
|
||||
- **GET** `/core/entities/search/` - Fuzzy search for entities
|
||||
- **GET** `/core/entities/not-found/` - Handle entity not found
|
||||
- **GET** `/core/entities/suggestions/` - Quick entity suggestions
|
||||
|
||||
---
|
||||
|
||||
## 📧 Email API (`/api/v1/email/`)
|
||||
|
||||
### Email Services
|
||||
- **POST** `/email/send/` - Send email
|
||||
|
||||
---
|
||||
|
||||
## 📜 History API (`/api/v1/history/`)
|
||||
|
||||
### Park History
|
||||
- **GET** `/history/parks/<park_slug>/` - Get park history
|
||||
- **GET** `/history/parks/<park_slug>/detail/` - Get detailed park history
|
||||
|
||||
### Ride History
|
||||
- **GET** `/history/parks/<park_slug>/rides/<ride_slug>/` - Get ride history
|
||||
- **GET** `/history/parks/<park_slug>/rides/<ride_slug>/detail/` - Get detailed ride history
|
||||
|
||||
### Unified Timeline
|
||||
- **GET** `/history/timeline/` - Get unified history timeline
|
||||
|
||||
---
|
||||
|
||||
## 📈 System & Analytics APIs
|
||||
|
||||
### Health Checks
|
||||
- **GET** `/api/v1/health/` - Comprehensive health check
|
||||
- **GET** `/api/v1/health/simple/` - Simple health check
|
||||
- **GET** `/api/v1/health/performance/` - Performance metrics
|
||||
|
||||
### Trending & Discovery
|
||||
- **GET** `/api/v1/trending/` - Get trending content
|
||||
- **GET** `/api/v1/new-content/` - Get new content
|
||||
- **POST** `/api/v1/trending/calculate/` - Trigger trending calculation
|
||||
|
||||
### Statistics
|
||||
- **GET** `/api/v1/stats/` - Get system statistics
|
||||
- **POST** `/api/v1/stats/recalculate/` - Recalculate statistics
|
||||
|
||||
### Reviews
|
||||
- **GET** `/api/v1/reviews/latest/` - Get latest reviews
|
||||
|
||||
### Rankings
|
||||
- **GET** `/api/v1/rankings/` - Get ride rankings with filtering
|
||||
- **GET** `/api/v1/rankings/<ride_slug>/` - Get detailed ranking for specific ride
|
||||
- **GET** `/api/v1/rankings/<ride_slug>/history/` - Get ranking history for ride
|
||||
- **GET** `/api/v1/rankings/<ride_slug>/comparisons/` - Get head-to-head comparisons
|
||||
- **GET** `/api/v1/rankings/statistics/` - Get ranking system statistics
|
||||
- **POST** `/api/v1/rankings/calculate/` - Trigger ranking calculation (admin)
|
||||
|
||||
#### Rankings Filtering Parameters:
|
||||
- **category**: Filter by ride category (RC, DR, FR, WR, TR, OT)
|
||||
- **min_riders**: Minimum number of mutual riders required
|
||||
- **park**: Filter by park slug
|
||||
- **ordering**: Order results (rank, -rank, winning_percentage, -winning_percentage)
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Moderation API (`/api/v1/moderation/`)
|
||||
|
||||
### Moderation Reports
|
||||
- **GET** `/moderation/reports/` - List all moderation reports
|
||||
- **POST** `/moderation/reports/` - Create new moderation report
|
||||
- **GET** `/moderation/reports/<id>/` - Get specific report details
|
||||
- **PUT** `/moderation/reports/<id>/` - Update moderation report
|
||||
- **PATCH** `/moderation/reports/<id>/` - Partial update report
|
||||
- **DELETE** `/moderation/reports/<id>/` - Delete moderation report
|
||||
- **POST** `/moderation/reports/<id>/assign/` - Assign report to moderator
|
||||
- **POST** `/moderation/reports/<id>/resolve/` - Resolve moderation report
|
||||
- **GET** `/moderation/reports/stats/` - Get report statistics
|
||||
|
||||
### Moderation Queue
|
||||
- **GET** `/moderation/queue/` - List moderation queue items
|
||||
- **POST** `/moderation/queue/` - Create queue item
|
||||
- **GET** `/moderation/queue/<id>/` - Get specific queue item
|
||||
- **PUT** `/moderation/queue/<id>/` - Update queue item
|
||||
- **PATCH** `/moderation/queue/<id>/` - Partial update queue item
|
||||
- **DELETE** `/moderation/queue/<id>/` - Delete queue item
|
||||
- **POST** `/moderation/queue/<id>/assign/` - Assign queue item to moderator
|
||||
- **POST** `/moderation/queue/<id>/unassign/` - Unassign queue item
|
||||
- **POST** `/moderation/queue/<id>/complete/` - Complete queue item
|
||||
- **GET** `/moderation/queue/my_queue/` - Get current user's queue items
|
||||
|
||||
### Moderation Actions
|
||||
- **GET** `/moderation/actions/` - List all moderation actions
|
||||
- **POST** `/moderation/actions/` - Create new moderation action
|
||||
- **GET** `/moderation/actions/<id>/` - Get specific action details
|
||||
- **PUT** `/moderation/actions/<id>/` - Update moderation action
|
||||
- **PATCH** `/moderation/actions/<id>/` - Partial update action
|
||||
- **DELETE** `/moderation/actions/<id>/` - Delete moderation action
|
||||
- **POST** `/moderation/actions/<id>/deactivate/` - Deactivate action
|
||||
- **GET** `/moderation/actions/active/` - Get active moderation actions
|
||||
- **GET** `/moderation/actions/expired/` - Get expired moderation actions
|
||||
|
||||
### Bulk Operations
|
||||
- **GET** `/moderation/bulk-operations/` - List bulk moderation operations
|
||||
- **POST** `/moderation/bulk-operations/` - Create bulk operation
|
||||
- **GET** `/moderation/bulk-operations/<id>/` - Get bulk operation details
|
||||
- **PUT** `/moderation/bulk-operations/<id>/` - Update bulk operation
|
||||
- **PATCH** `/moderation/bulk-operations/<id>/` - Partial update operation
|
||||
- **DELETE** `/moderation/bulk-operations/<id>/` - Delete bulk operation
|
||||
- **POST** `/moderation/bulk-operations/<id>/cancel/` - Cancel bulk operation
|
||||
- **POST** `/moderation/bulk-operations/<id>/retry/` - Retry failed operation
|
||||
- **GET** `/moderation/bulk-operations/<id>/logs/` - Get operation logs
|
||||
- **GET** `/moderation/bulk-operations/running/` - Get running operations
|
||||
|
||||
### User Moderation
|
||||
- **GET** `/moderation/users/<id>/` - Get user moderation profile
|
||||
- **POST** `/moderation/users/<id>/moderate/` - Take moderation action against user
|
||||
- **GET** `/moderation/users/search/` - Search users for moderation
|
||||
- **GET** `/moderation/users/stats/` - Get user moderation statistics
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Ride Manufacturers & Models (`/api/v1/rides/manufacturers/<manufacturer_slug>/`)
|
||||
|
||||
### Ride Models
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/` - List ride models by manufacturer
|
||||
- **POST** `/rides/manufacturers/<manufacturer_slug>/` - Create new ride model
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/` - Get ride model details
|
||||
- **PATCH** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/` - Update ride model
|
||||
- **DELETE** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/` - Delete ride model
|
||||
|
||||
### Model Search & Filtering
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/search/` - Search ride models
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/filter-options/` - Get filter options
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/stats/` - Get manufacturer statistics
|
||||
|
||||
### Model Variants
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/variants/` - List model variants
|
||||
- **POST** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/variants/` - Create variant
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/variants/<id>/` - Get variant details
|
||||
- **PATCH** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/variants/<id>/` - Update variant
|
||||
- **DELETE** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/variants/<id>/` - Delete variant
|
||||
|
||||
### Technical Specifications
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/technical-specs/` - List technical specs
|
||||
- **POST** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/technical-specs/` - Create technical spec
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/technical-specs/<id>/` - Get spec details
|
||||
- **PATCH** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/technical-specs/<id>/` - Update spec
|
||||
- **DELETE** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/technical-specs/<id>/` - Delete spec
|
||||
|
||||
### Model Photos
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/photos/` - List model photos
|
||||
- **POST** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/photos/` - Upload model photo
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/photos/<id>/` - Get photo details
|
||||
- **PATCH** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/photos/<id>/` - Update photo
|
||||
- **DELETE** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/photos/<id>/` - Delete photo
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ Media Management
|
||||
|
||||
### Cloudflare Images
|
||||
- **ALL** `/api/v1/cloudflare-images/` - Cloudflare Images toolkit endpoints
|
||||
|
||||
---
|
||||
|
||||
## 📚 API Documentation
|
||||
|
||||
### Interactive Documentation
|
||||
- **GET** `/api/schema/` - OpenAPI schema
|
||||
- **GET** `/api/docs/` - Swagger UI documentation
|
||||
- **GET** `/api/redoc/` - ReDoc documentation
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Common Request/Response Patterns
|
||||
|
||||
### Authentication Headers
|
||||
```javascript
|
||||
headers: {
|
||||
'Authorization': 'Bearer <access_token>',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
```
|
||||
|
||||
### Pagination Response
|
||||
```json
|
||||
{
|
||||
"count": 100,
|
||||
"next": "http://api.example.com/api/v1/endpoint/?page=2",
|
||||
"previous": null,
|
||||
"results": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response Format
|
||||
```json
|
||||
{
|
||||
"error": "Error message",
|
||||
"error_code": "SPECIFIC_ERROR_CODE",
|
||||
"details": {...},
|
||||
"suggestions": ["suggestion1", "suggestion2"]
|
||||
}
|
||||
```
|
||||
|
||||
### Success Response Format
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Operation completed successfully",
|
||||
"data": {...}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Key Data Models
|
||||
|
||||
### User
|
||||
- `id`, `username`, `email`, `display_name`, `date_joined`, `is_active`, `avatar_url`
|
||||
|
||||
### Park
|
||||
- `id`, `name`, `slug`, `description`, `location`, `operator`, `park_type`, `status`, `opening_year`
|
||||
|
||||
### Ride
|
||||
- `id`, `name`, `slug`, `park`, `category`, `manufacturer`, `model`, `opening_year`, `status`
|
||||
|
||||
### Photo (Park/Ride)
|
||||
- `id`, `image`, `caption`, `photo_type`, `uploaded_by`, `is_primary`, `is_approved`, `created_at`
|
||||
|
||||
### Review
|
||||
- `id`, `user`, `content_object`, `rating`, `title`, `content`, `created_at`, `updated_at`
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Important Notes
|
||||
|
||||
1. **Authentication Required**: Most endpoints require JWT authentication
|
||||
2. **Permissions**: Admin endpoints require staff/superuser privileges
|
||||
3. **Rate Limiting**: May be implemented on certain endpoints
|
||||
4. **File Uploads**: Use `multipart/form-data` for photo uploads
|
||||
5. **Pagination**: Most list endpoints support pagination with `page` and `page_size` parameters
|
||||
6. **Filtering**: Parks and rides support extensive filtering options
|
||||
7. **Cloudflare Images**: Media files are handled through Cloudflare Images service
|
||||
8. **Email Verification**: New users must verify email before full access
|
||||
|
||||
---
|
||||
|
||||
## 📖 Usage Examples
|
||||
|
||||
### Authentication Flow
|
||||
```javascript
|
||||
// Login
|
||||
const login = await fetch('/api/v1/auth/login/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'user@example.com', password: 'password' })
|
||||
});
|
||||
|
||||
// Use tokens from response
|
||||
const { access, refresh } = await login.json();
|
||||
```
|
||||
|
||||
### Fetch Parks with Filtering
|
||||
```javascript
|
||||
const parks = await fetch('/api/v1/parks/?continent=NA&min_rating=4.0&page=1', {
|
||||
headers: { 'Authorization': `Bearer ${access_token}` }
|
||||
});
|
||||
```
|
||||
|
||||
### Upload Park Photo
|
||||
```javascript
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
formData.append('caption', 'Beautiful park entrance');
|
||||
|
||||
const photo = await fetch('/api/v1/parks/123/photos/', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${access_token}` },
|
||||
body: formData
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
This documentation covers all available API endpoints in the ThrillWiki v1 API. For detailed request/response schemas, parameter validation, and interactive testing, visit `/api/docs/` when the development server is running.
|
||||
649
api_endpoints_curl_commands.sh
Executable file
649
api_endpoints_curl_commands.sh
Executable 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"
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,76 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-21 01:29
|
||||
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="userprofile",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="userprofile",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="avatar",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="django_cloudflareimages_toolkit.cloudflareimage",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofileevent",
|
||||
name="avatar",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="django_cloudflareimages_toolkit.cloudflareimage",
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="userprofile",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_userprofileevent" ("avatar_id", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar_id", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
|
||||
hash="a7ecdb1ac2821dea1fef4ec917eeaf6b8e4f09c8",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_c09d7",
|
||||
table="accounts_userprofile",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="userprofile",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_userprofileevent" ("avatar_id", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar_id", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
|
||||
hash="81607e492ffea2a4c741452b860ee660374cc01d",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_87ef6",
|
||||
table="accounts_userprofile",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,97 +0,0 @@
|
||||
"""
|
||||
Modern Security Headers Middleware for ThrillWiki
|
||||
Implements Content Security Policy and other modern security headers.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import base64
|
||||
from django.conf import settings
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
Middleware to add modern security headers to all responses.
|
||||
"""
|
||||
|
||||
def _generate_nonce(self):
|
||||
"""Generate a cryptographically secure nonce for CSP."""
|
||||
# Generate 16 random bytes and encode as base64
|
||||
return base64.b64encode(secrets.token_bytes(16)).decode('ascii')
|
||||
|
||||
def _modify_csp_with_nonce(self, csp_policy, nonce):
|
||||
"""Modify CSP policy to include nonce for script-src."""
|
||||
if not csp_policy:
|
||||
return csp_policy
|
||||
|
||||
# Look for script-src directive and add nonce
|
||||
directives = csp_policy.split(';')
|
||||
modified_directives = []
|
||||
|
||||
for directive in directives:
|
||||
directive = directive.strip()
|
||||
if directive.startswith('script-src '):
|
||||
# Add nonce to script-src directive
|
||||
directive += f" 'nonce-{nonce}'"
|
||||
modified_directives.append(directive)
|
||||
|
||||
return '; '.join(modified_directives)
|
||||
|
||||
def process_request(self, request):
|
||||
"""Generate and store nonce for this request."""
|
||||
# Generate a nonce for this request
|
||||
nonce = self._generate_nonce()
|
||||
# Store it in request so templates can access it
|
||||
request.csp_nonce = nonce
|
||||
return None
|
||||
|
||||
def process_response(self, request, response):
|
||||
"""Add security headers to the response."""
|
||||
|
||||
# Content Security Policy with nonce support
|
||||
if hasattr(settings, 'SECURE_CONTENT_SECURITY_POLICY'):
|
||||
csp_policy = settings.SECURE_CONTENT_SECURITY_POLICY
|
||||
# Apply nonce if we have one for this request
|
||||
if hasattr(request, 'csp_nonce'):
|
||||
csp_policy = self._modify_csp_with_nonce(csp_policy, request.csp_nonce)
|
||||
response['Content-Security-Policy'] = csp_policy
|
||||
|
||||
# Cross-Origin Opener Policy
|
||||
if hasattr(settings, 'SECURE_CROSS_ORIGIN_OPENER_POLICY'):
|
||||
response['Cross-Origin-Opener-Policy'] = settings.SECURE_CROSS_ORIGIN_OPENER_POLICY
|
||||
|
||||
# Referrer Policy
|
||||
if hasattr(settings, 'SECURE_REFERRER_POLICY'):
|
||||
response['Referrer-Policy'] = settings.SECURE_REFERRER_POLICY
|
||||
|
||||
# Permissions Policy
|
||||
if hasattr(settings, 'SECURE_PERMISSIONS_POLICY'):
|
||||
response['Permissions-Policy'] = settings.SECURE_PERMISSIONS_POLICY
|
||||
|
||||
# Additional security headers
|
||||
response['X-Content-Type-Options'] = 'nosniff'
|
||||
response['X-Frame-Options'] = getattr(settings, 'X_FRAME_OPTIONS', 'DENY')
|
||||
response['X-XSS-Protection'] = '1; mode=block'
|
||||
|
||||
# Cache Control headers for better performance
|
||||
# Prevent caching of HTML pages to ensure users get fresh content
|
||||
if response.get('Content-Type', '').startswith('text/html'):
|
||||
response['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||
response['Pragma'] = 'no-cache'
|
||||
response['Expires'] = '0'
|
||||
|
||||
# Strict Transport Security (if SSL is enabled)
|
||||
if getattr(settings, 'SECURE_SSL_REDIRECT', False):
|
||||
hsts_seconds = getattr(settings, 'SECURE_HSTS_SECONDS', 31536000)
|
||||
hsts_include_subdomains = getattr(settings, 'SECURE_HSTS_INCLUDE_SUBDOMAINS', True)
|
||||
hsts_preload = getattr(settings, 'SECURE_HSTS_PRELOAD', False)
|
||||
|
||||
hsts_header = f'max-age={hsts_seconds}'
|
||||
if hsts_include_subdomains:
|
||||
hsts_header += '; includeSubDomains'
|
||||
if hsts_preload:
|
||||
hsts_header += '; preload'
|
||||
|
||||
response['Strict-Transport-Security'] = hsts_header
|
||||
|
||||
return response
|
||||
@@ -1,292 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-21 01:27
|
||||
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
("pghistory", "0007_auto_20250421_0444"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="PageView",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("object_id", models.PositiveIntegerField()),
|
||||
("timestamp", models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
("ip_address", models.GenericIPAddressField()),
|
||||
("user_agent", models.CharField(blank=True, max_length=512)),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="page_views",
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PageViewEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("object_id", models.PositiveIntegerField()),
|
||||
("timestamp", models.DateTimeField(auto_now_add=True)),
|
||||
("ip_address", models.GenericIPAddressField()),
|
||||
("user_agent", models.CharField(blank=True, max_length=512)),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_context",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_obj",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="core.pageview",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SlugHistory",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("object_id", models.CharField(max_length=50)),
|
||||
("old_slug", models.SlugField(max_length=200)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name_plural": "Slug histories",
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SlugHistoryEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("object_id", models.CharField(max_length=50)),
|
||||
("old_slug", models.SlugField(db_index=False, max_length=200)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_context",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_obj",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="core.slughistory",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="HistoricalSlug",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("object_id", models.PositiveIntegerField()),
|
||||
("slug", models.SlugField(max_length=255)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="historical_slugs",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["content_type", "object_id"],
|
||||
name="core_histor_content_b4c470_idx",
|
||||
),
|
||||
models.Index(fields=["slug"], name="core_histor_slug_8fd7b3_idx"),
|
||||
],
|
||||
"unique_together": {("content_type", "slug")},
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="pageview",
|
||||
index=models.Index(
|
||||
fields=["timestamp"], name="core_pagevi_timesta_757ebb_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="pageview",
|
||||
index=models.Index(
|
||||
fields=["content_type", "object_id"],
|
||||
name="core_pagevi_content_eda7ad_idx",
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="pageview",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "core_pageviewevent" ("content_type_id", "id", "ip_address", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "timestamp", "user_agent") VALUES (NEW."content_type_id", NEW."id", NEW."ip_address", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."timestamp", NEW."user_agent"); RETURN NULL;',
|
||||
hash="1682d124ea3ba215e630c7cfcde929f7444cf247",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_ee1e1",
|
||||
table="core_pageview",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="pageview",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "core_pageviewevent" ("content_type_id", "id", "ip_address", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "timestamp", "user_agent") VALUES (NEW."content_type_id", NEW."id", NEW."ip_address", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."timestamp", NEW."user_agent"); RETURN NULL;',
|
||||
hash="4221b2dd6636cae454f8d69c0c1841c40c47e6a6",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_3c505",
|
||||
table="core_pageview",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="slughistory",
|
||||
index=models.Index(
|
||||
fields=["content_type", "object_id"],
|
||||
name="core_slughi_content_8bbf56_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="slughistory",
|
||||
index=models.Index(
|
||||
fields=["old_slug"], name="core_slughi_old_slu_aaef7f_idx"
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="slughistory",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "core_slughistoryevent" ("content_type_id", "created_at", "id", "object_id", "old_slug", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."id", NEW."object_id", NEW."old_slug", _pgh_attach_context(), NOW(), \'insert\', NEW."id"); RETURN NULL;',
|
||||
hash="2a2a05025693c165b88e5eba7fcc23214749a78b",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_3002a",
|
||||
table="core_slughistory",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="slughistory",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "core_slughistoryevent" ("content_type_id", "created_at", "id", "object_id", "old_slug", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."id", NEW."object_id", NEW."old_slug", _pgh_attach_context(), NOW(), \'update\', NEW."id"); RETURN NULL;',
|
||||
hash="3ad197ccb6178668e762720341e45d3fd3216776",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_52030",
|
||||
table="core_slughistory",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,198 +0,0 @@
|
||||
"""
|
||||
Django management command to run performance benchmarks.
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
import json
|
||||
import time
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Run comprehensive performance benchmarks for park listing features'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--save',
|
||||
action='store_true',
|
||||
help='Save detailed benchmark results to file',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--autocomplete-only',
|
||||
action='store_true',
|
||||
help='Run only autocomplete benchmarks',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--listing-only',
|
||||
action='store_true',
|
||||
help='Run only listing benchmarks',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--pagination-only',
|
||||
action='store_true',
|
||||
help='Run only pagination benchmarks',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--iterations',
|
||||
type=int,
|
||||
default=1,
|
||||
help='Number of iterations to run (default: 1)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from apps.parks.services.performance_monitoring import BenchmarkSuite
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS('Starting Park Listing Performance Benchmarks')
|
||||
)
|
||||
|
||||
suite = BenchmarkSuite()
|
||||
iterations = options['iterations']
|
||||
all_results = []
|
||||
|
||||
for i in range(iterations):
|
||||
if iterations > 1:
|
||||
self.stdout.write(f'\nIteration {i + 1}/{iterations}')
|
||||
|
||||
start_time = time.perf_counter()
|
||||
|
||||
# Run specific benchmarks or full suite
|
||||
if options['autocomplete_only']:
|
||||
result = suite.run_autocomplete_benchmark()
|
||||
elif options['listing_only']:
|
||||
result = suite.run_listing_benchmark()
|
||||
elif options['pagination_only']:
|
||||
result = suite.run_pagination_benchmark()
|
||||
else:
|
||||
result = suite.run_full_benchmark_suite()
|
||||
|
||||
duration = time.perf_counter() - start_time
|
||||
result['iteration'] = i + 1
|
||||
result['benchmark_duration'] = duration
|
||||
all_results.append(result)
|
||||
|
||||
# Display summary for this iteration
|
||||
self._display_iteration_summary(result, duration)
|
||||
|
||||
# Display overall summary if multiple iterations
|
||||
if iterations > 1:
|
||||
self._display_overall_summary(all_results)
|
||||
|
||||
# Save results if requested
|
||||
if options['save']:
|
||||
self._save_results(all_results)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS('\\nBenchmark completed successfully!')
|
||||
)
|
||||
|
||||
def _display_iteration_summary(self, result, duration):
|
||||
"""Display summary for a single iteration."""
|
||||
|
||||
if 'overall_summary' in result:
|
||||
summary = result['overall_summary']
|
||||
|
||||
self.stdout.write(f'\\nBenchmark Duration: {duration:.3f}s')
|
||||
self.stdout.write(f'Total Operations: {summary["total_operations"]}')
|
||||
self.stdout.write(f'Average Response Time: {summary["duration_stats"]["mean"]:.3f}s')
|
||||
self.stdout.write(f'Average Query Count: {summary["query_stats"]["mean"]:.1f}')
|
||||
self.stdout.write(f'Cache Hit Rate: {summary["cache_stats"]["hit_rate"]:.1f}%')
|
||||
|
||||
# Display slowest operations
|
||||
if summary.get('slowest_operations'):
|
||||
self.stdout.write('\\nSlowest Operations:')
|
||||
for op in summary['slowest_operations'][:3]:
|
||||
self.stdout.write(f' {op["operation"]}: {op["duration"]:.3f}s ({op["query_count"]} queries)')
|
||||
|
||||
# Display recommendations
|
||||
if result.get('recommendations'):
|
||||
self.stdout.write('\\nRecommendations:')
|
||||
for rec in result['recommendations']:
|
||||
self.stdout.write(f' • {rec}')
|
||||
|
||||
# Display specific benchmark results
|
||||
for benchmark_type in ['autocomplete', 'listing', 'pagination']:
|
||||
if benchmark_type in result:
|
||||
self._display_benchmark_results(benchmark_type, result[benchmark_type])
|
||||
|
||||
def _display_benchmark_results(self, benchmark_type, results):
|
||||
"""Display results for a specific benchmark type."""
|
||||
self.stdout.write(f'\\n{benchmark_type.title()} Benchmark Results:')
|
||||
|
||||
if benchmark_type == 'autocomplete':
|
||||
for query_result in results.get('results', []):
|
||||
self.stdout.write(
|
||||
f' Query "{query_result["query"]}": {query_result["response_time"]:.3f}s '
|
||||
f'({query_result["query_count"]} queries)'
|
||||
)
|
||||
|
||||
elif benchmark_type == 'listing':
|
||||
for scenario in results.get('results', []):
|
||||
self.stdout.write(
|
||||
f' {scenario["scenario"]}: {scenario["response_time"]:.3f}s '
|
||||
f'({scenario["query_count"]} queries, {scenario["result_count"]} results)'
|
||||
)
|
||||
|
||||
elif benchmark_type == 'pagination':
|
||||
# Group by page size for cleaner display
|
||||
by_page_size = {}
|
||||
for result in results.get('results', []):
|
||||
size = result['page_size']
|
||||
if size not in by_page_size:
|
||||
by_page_size[size] = []
|
||||
by_page_size[size].append(result)
|
||||
|
||||
for page_size, page_results in by_page_size.items():
|
||||
avg_time = sum(r['response_time'] for r in page_results) / len(page_results)
|
||||
avg_queries = sum(r['query_count'] for r in page_results) / len(page_results)
|
||||
self.stdout.write(
|
||||
f' Page size {page_size}: avg {avg_time:.3f}s ({avg_queries:.1f} queries)'
|
||||
)
|
||||
|
||||
def _display_overall_summary(self, all_results):
|
||||
"""Display summary across all iterations."""
|
||||
self.stdout.write('\\n' + '='*50)
|
||||
self.stdout.write('OVERALL SUMMARY ACROSS ALL ITERATIONS')
|
||||
self.stdout.write('='*50)
|
||||
|
||||
# Calculate averages across iterations
|
||||
total_duration = sum(r['benchmark_duration'] for r in all_results)
|
||||
|
||||
# Extract performance metrics from iterations with overall_summary
|
||||
overall_summaries = [r['overall_summary'] for r in all_results if 'overall_summary' in r]
|
||||
|
||||
if overall_summaries:
|
||||
avg_response_time = sum(s['duration_stats']['mean'] for s in overall_summaries) / len(overall_summaries)
|
||||
avg_query_count = sum(s['query_stats']['mean'] for s in overall_summaries) / len(overall_summaries)
|
||||
avg_cache_hit_rate = sum(s['cache_stats']['hit_rate'] for s in overall_summaries) / len(overall_summaries)
|
||||
|
||||
self.stdout.write(f'Total Benchmark Time: {total_duration:.3f}s')
|
||||
self.stdout.write(f'Average Response Time: {avg_response_time:.3f}s')
|
||||
self.stdout.write(f'Average Query Count: {avg_query_count:.1f}')
|
||||
self.stdout.write(f'Average Cache Hit Rate: {avg_cache_hit_rate:.1f}%')
|
||||
|
||||
def _save_results(self, results):
|
||||
"""Save benchmark results to file."""
|
||||
timestamp = timezone.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f'benchmark_results_{timestamp}.json'
|
||||
|
||||
try:
|
||||
import os
|
||||
|
||||
# Ensure logs directory exists
|
||||
logs_dir = 'logs'
|
||||
os.makedirs(logs_dir, exist_ok=True)
|
||||
|
||||
filepath = os.path.join(logs_dir, filename)
|
||||
|
||||
with open(filepath, 'w') as f:
|
||||
json.dump(results, f, indent=2, default=str)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'Results saved to {filepath}')
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'Error saving results: {e}')
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,54 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-23 22:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('parks', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Performance indexes for frequently filtered fields
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX IF NOT EXISTS idx_parks_status_operator ON parks_park(status, operator_id);",
|
||||
reverse_sql="DROP INDEX IF EXISTS idx_parks_status_operator;"
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX IF NOT EXISTS idx_parks_park_type_status ON parks_park(park_type, status);",
|
||||
reverse_sql="DROP INDEX IF EXISTS idx_parks_park_type_status;"
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX IF NOT EXISTS idx_parks_opening_year_status ON parks_park(opening_year, status) WHERE opening_year IS NOT NULL;",
|
||||
reverse_sql="DROP INDEX IF EXISTS idx_parks_opening_year_status;"
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX IF NOT EXISTS idx_parks_ride_count_coaster_count ON parks_park(ride_count, coaster_count) WHERE ride_count IS NOT NULL;",
|
||||
reverse_sql="DROP INDEX IF EXISTS idx_parks_ride_count_coaster_count;"
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX IF NOT EXISTS idx_parks_average_rating_status ON parks_park(average_rating, status) WHERE average_rating IS NOT NULL;",
|
||||
reverse_sql="DROP INDEX IF EXISTS idx_parks_average_rating_status;"
|
||||
),
|
||||
# Search optimization index
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX IF NOT EXISTS idx_parks_search_text_gin ON parks_park USING gin(search_text gin_trgm_ops);",
|
||||
reverse_sql="DROP INDEX IF EXISTS idx_parks_search_text_gin;"
|
||||
),
|
||||
# Location-based indexes for ParkLocation
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX IF NOT EXISTS idx_parklocation_country_city ON parks_parklocation(country, city);",
|
||||
reverse_sql="DROP INDEX IF EXISTS idx_parklocation_country_city;"
|
||||
),
|
||||
# Company name index for operator filtering
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX IF NOT EXISTS idx_company_name_roles ON parks_company USING gin(name gin_trgm_ops, roles);",
|
||||
reverse_sql="DROP INDEX IF EXISTS idx_company_name_roles;"
|
||||
),
|
||||
# Timestamps for ordering and filtering
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX IF NOT EXISTS idx_parks_created_at_status ON parks_park(created_at, status);",
|
||||
reverse_sql="DROP INDEX IF EXISTS idx_parks_created_at_status;"
|
||||
),
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
@@ -1,311 +0,0 @@
|
||||
"""
|
||||
Optimized pagination service for large datasets with efficient counting.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
from django.core.paginator import Paginator, Page
|
||||
from django.core.cache import cache
|
||||
from django.db.models import QuerySet, Count
|
||||
from django.conf import settings
|
||||
import hashlib
|
||||
import time
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("pagination_service")
|
||||
|
||||
|
||||
class OptimizedPaginator(Paginator):
|
||||
"""
|
||||
Custom paginator that optimizes COUNT queries and provides caching.
|
||||
"""
|
||||
|
||||
def __init__(self, object_list, per_page, cache_timeout=300, **kwargs):
|
||||
super().__init__(object_list, per_page, **kwargs)
|
||||
self.cache_timeout = cache_timeout
|
||||
self._cached_count = None
|
||||
self._count_cache_key = None
|
||||
|
||||
def _get_count_cache_key(self) -> str:
|
||||
"""Generate cache key for count based on queryset SQL."""
|
||||
if self._count_cache_key:
|
||||
return self._count_cache_key
|
||||
|
||||
# Create cache key from queryset SQL
|
||||
if hasattr(self.object_list, 'query'):
|
||||
sql_hash = hashlib.md5(
|
||||
str(self.object_list.query).encode('utf-8')
|
||||
).hexdigest()[:16]
|
||||
self._count_cache_key = f"paginator_count:{sql_hash}"
|
||||
else:
|
||||
# Fallback for non-queryset object lists
|
||||
self._count_cache_key = f"paginator_count:list:{len(self.object_list)}"
|
||||
|
||||
return self._count_cache_key
|
||||
|
||||
@property
|
||||
def count(self):
|
||||
"""
|
||||
Optimized count with caching for expensive querysets.
|
||||
"""
|
||||
if self._cached_count is not None:
|
||||
return self._cached_count
|
||||
|
||||
cache_key = self._get_count_cache_key()
|
||||
cached_count = cache.get(cache_key)
|
||||
|
||||
if cached_count is not None:
|
||||
logger.debug(f"Cache hit for pagination count: {cache_key}")
|
||||
self._cached_count = cached_count
|
||||
return cached_count
|
||||
|
||||
# Perform optimized count
|
||||
start_time = time.time()
|
||||
|
||||
if hasattr(self.object_list, 'count'):
|
||||
# For QuerySets, try to optimize the count query
|
||||
count = self._get_optimized_count()
|
||||
else:
|
||||
count = len(self.object_list)
|
||||
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
# Cache the result
|
||||
cache.set(cache_key, count, self.cache_timeout)
|
||||
self._cached_count = count
|
||||
|
||||
if execution_time > 0.5: # Log slow count queries
|
||||
logger.warning(
|
||||
f"Slow pagination count query: {execution_time:.3f}s for {count} items",
|
||||
extra={'cache_key': cache_key, 'execution_time': execution_time}
|
||||
)
|
||||
|
||||
return count
|
||||
|
||||
def _get_optimized_count(self) -> int:
|
||||
"""
|
||||
Get optimized count for complex querysets.
|
||||
"""
|
||||
queryset = self.object_list
|
||||
|
||||
# For complex queries with joins, use approximate counting for very large datasets
|
||||
if self._is_complex_query(queryset):
|
||||
# Try to get count from a simpler subquery
|
||||
try:
|
||||
# Use subquery approach for complex queries
|
||||
subquery = queryset.values('pk')
|
||||
return subquery.count()
|
||||
except Exception as e:
|
||||
logger.warning(f"Optimized count failed, falling back to standard count: {e}")
|
||||
return queryset.count()
|
||||
else:
|
||||
return queryset.count()
|
||||
|
||||
def _is_complex_query(self, queryset) -> bool:
|
||||
"""
|
||||
Determine if a queryset is complex and might benefit from optimization.
|
||||
"""
|
||||
if not hasattr(queryset, 'query'):
|
||||
return False
|
||||
|
||||
sql = str(queryset.query).upper()
|
||||
|
||||
# Consider complex if it has multiple joins or subqueries
|
||||
complexity_indicators = [
|
||||
'JOIN' in sql and sql.count('JOIN') > 2,
|
||||
'DISTINCT' in sql,
|
||||
'GROUP BY' in sql,
|
||||
'HAVING' in sql,
|
||||
]
|
||||
|
||||
return any(complexity_indicators)
|
||||
|
||||
|
||||
class CursorPaginator:
|
||||
"""
|
||||
Cursor-based pagination for very large datasets.
|
||||
More efficient than offset-based pagination for large page numbers.
|
||||
"""
|
||||
|
||||
def __init__(self, queryset: QuerySet, ordering_field: str = 'id', per_page: int = 20):
|
||||
self.queryset = queryset
|
||||
self.ordering_field = ordering_field
|
||||
self.per_page = per_page
|
||||
self.reverse = ordering_field.startswith('-')
|
||||
self.field_name = ordering_field.lstrip('-')
|
||||
|
||||
def get_page(self, cursor: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get a page of results using cursor-based pagination.
|
||||
|
||||
Args:
|
||||
cursor: Base64 encoded cursor value from previous page
|
||||
|
||||
Returns:
|
||||
Dictionary with page data and navigation cursors
|
||||
"""
|
||||
queryset = self.queryset.order_by(self.ordering_field)
|
||||
|
||||
if cursor:
|
||||
# Decode cursor and filter from that point
|
||||
try:
|
||||
cursor_value = self._decode_cursor(cursor)
|
||||
if self.reverse:
|
||||
queryset = queryset.filter(**{f"{self.field_name}__lt": cursor_value})
|
||||
else:
|
||||
queryset = queryset.filter(**{f"{self.field_name}__gt": cursor_value})
|
||||
except (ValueError, TypeError):
|
||||
# Invalid cursor, start from beginning
|
||||
pass
|
||||
|
||||
# Get one extra item to check if there's a next page
|
||||
items = list(queryset[:self.per_page + 1])
|
||||
has_next = len(items) > self.per_page
|
||||
|
||||
if has_next:
|
||||
items = items[:-1] # Remove the extra item
|
||||
|
||||
# Generate cursors for navigation
|
||||
next_cursor = None
|
||||
previous_cursor = None
|
||||
|
||||
if items and has_next:
|
||||
last_item = items[-1]
|
||||
next_cursor = self._encode_cursor(getattr(last_item, self.field_name))
|
||||
|
||||
if items and cursor:
|
||||
first_item = items[0]
|
||||
previous_cursor = self._encode_cursor(getattr(first_item, self.field_name))
|
||||
|
||||
return {
|
||||
'items': items,
|
||||
'has_next': has_next,
|
||||
'has_previous': cursor is not None,
|
||||
'next_cursor': next_cursor,
|
||||
'previous_cursor': previous_cursor,
|
||||
'count': len(items)
|
||||
}
|
||||
|
||||
def _encode_cursor(self, value) -> str:
|
||||
"""Encode cursor value to base64 string."""
|
||||
import base64
|
||||
return base64.b64encode(str(value).encode()).decode()
|
||||
|
||||
def _decode_cursor(self, cursor: str):
|
||||
"""Decode cursor from base64 string."""
|
||||
import base64
|
||||
decoded = base64.b64decode(cursor.encode()).decode()
|
||||
|
||||
# Try to convert to appropriate type based on field
|
||||
field = self.queryset.model._meta.get_field(self.field_name)
|
||||
|
||||
if hasattr(field, 'to_python'):
|
||||
return field.to_python(decoded)
|
||||
return decoded
|
||||
|
||||
|
||||
class PaginationCache:
|
||||
"""
|
||||
Advanced caching for pagination metadata and results.
|
||||
"""
|
||||
|
||||
CACHE_PREFIX = "pagination"
|
||||
DEFAULT_TIMEOUT = 300 # 5 minutes
|
||||
|
||||
@classmethod
|
||||
def get_page_cache_key(cls, queryset_hash: str, page_num: int) -> str:
|
||||
"""Generate cache key for a specific page."""
|
||||
return f"{cls.CACHE_PREFIX}:page:{queryset_hash}:{page_num}"
|
||||
|
||||
@classmethod
|
||||
def get_metadata_cache_key(cls, queryset_hash: str) -> str:
|
||||
"""Generate cache key for pagination metadata."""
|
||||
return f"{cls.CACHE_PREFIX}:meta:{queryset_hash}"
|
||||
|
||||
@classmethod
|
||||
def cache_page_results(
|
||||
cls,
|
||||
queryset_hash: str,
|
||||
page_num: int,
|
||||
page_data: Dict[str, Any],
|
||||
timeout: int = DEFAULT_TIMEOUT
|
||||
):
|
||||
"""Cache page results."""
|
||||
cache_key = cls.get_page_cache_key(queryset_hash, page_num)
|
||||
cache.set(cache_key, page_data, timeout)
|
||||
|
||||
@classmethod
|
||||
def get_cached_page(cls, queryset_hash: str, page_num: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get cached page results."""
|
||||
cache_key = cls.get_page_cache_key(queryset_hash, page_num)
|
||||
return cache.get(cache_key)
|
||||
|
||||
@classmethod
|
||||
def cache_metadata(
|
||||
cls,
|
||||
queryset_hash: str,
|
||||
metadata: Dict[str, Any],
|
||||
timeout: int = DEFAULT_TIMEOUT
|
||||
):
|
||||
"""Cache pagination metadata."""
|
||||
cache_key = cls.get_metadata_cache_key(queryset_hash)
|
||||
cache.set(cache_key, metadata, timeout)
|
||||
|
||||
@classmethod
|
||||
def get_cached_metadata(cls, queryset_hash: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get cached pagination metadata."""
|
||||
cache_key = cls.get_metadata_cache_key(queryset_hash)
|
||||
return cache.get(cache_key)
|
||||
|
||||
@classmethod
|
||||
def invalidate_cache(cls, queryset_hash: str):
|
||||
"""Invalidate all cache entries for a queryset."""
|
||||
# This would require a cache backend that supports pattern deletion
|
||||
# For now, we'll rely on TTL expiration
|
||||
pass
|
||||
|
||||
|
||||
def get_optimized_page(
|
||||
queryset: QuerySet,
|
||||
page_number: int,
|
||||
per_page: int = 20,
|
||||
use_cursor: bool = False,
|
||||
cursor: Optional[str] = None,
|
||||
cache_timeout: int = 300
|
||||
) -> Tuple[Page, Dict[str, Any]]:
|
||||
"""
|
||||
Get an optimized page with caching and performance monitoring.
|
||||
|
||||
Args:
|
||||
queryset: The queryset to paginate
|
||||
page_number: Page number to retrieve
|
||||
per_page: Items per page
|
||||
use_cursor: Whether to use cursor-based pagination
|
||||
cursor: Cursor for cursor-based pagination
|
||||
cache_timeout: Cache timeout in seconds
|
||||
|
||||
Returns:
|
||||
Tuple of (Page object, metadata dict)
|
||||
"""
|
||||
if use_cursor:
|
||||
paginator = CursorPaginator(queryset, per_page=per_page)
|
||||
page_data = paginator.get_page(cursor)
|
||||
|
||||
return page_data, {
|
||||
'pagination_type': 'cursor',
|
||||
'has_next': page_data['has_next'],
|
||||
'has_previous': page_data['has_previous'],
|
||||
'next_cursor': page_data['next_cursor'],
|
||||
'previous_cursor': page_data['previous_cursor']
|
||||
}
|
||||
else:
|
||||
paginator = OptimizedPaginator(queryset, per_page, cache_timeout=cache_timeout)
|
||||
page = paginator.get_page(page_number)
|
||||
|
||||
return page, {
|
||||
'pagination_type': 'offset',
|
||||
'total_pages': paginator.num_pages,
|
||||
'total_count': paginator.count,
|
||||
'has_next': page.has_next(),
|
||||
'has_previous': page.has_previous(),
|
||||
'current_page': page.number
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
"""
|
||||
Performance monitoring and benchmarking tools for park listing optimizations.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
import statistics
|
||||
from typing import Dict, List, Any, Optional, Callable
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from django.db import connection
|
||||
from django.core.cache import cache
|
||||
from django.conf import settings
|
||||
from django.test import RequestFactory
|
||||
import json
|
||||
|
||||
logger = logging.getLogger("performance_monitoring")
|
||||
|
||||
|
||||
@dataclass
|
||||
class PerformanceMetric:
|
||||
"""Data class for storing performance metrics."""
|
||||
operation: str
|
||||
duration: float
|
||||
query_count: int
|
||||
cache_hits: int = 0
|
||||
cache_misses: int = 0
|
||||
memory_usage: Optional[float] = None
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class PerformanceMonitor:
|
||||
"""
|
||||
Comprehensive performance monitoring for park listing operations.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.metrics: List[PerformanceMetric] = []
|
||||
self.cache_stats = {'hits': 0, 'misses': 0}
|
||||
|
||||
@contextmanager
|
||||
def measure_operation(self, operation_name: str, **metadata):
|
||||
"""Context manager to measure operation performance."""
|
||||
initial_queries = len(connection.queries) if hasattr(connection, 'queries') else 0
|
||||
initial_cache_hits = self.cache_stats['hits']
|
||||
initial_cache_misses = self.cache_stats['misses']
|
||||
|
||||
start_time = time.perf_counter()
|
||||
start_memory = self._get_memory_usage()
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
end_time = time.perf_counter()
|
||||
end_memory = self._get_memory_usage()
|
||||
|
||||
duration = end_time - start_time
|
||||
query_count = (len(connection.queries) - initial_queries) if hasattr(connection, 'queries') else 0
|
||||
cache_hits = self.cache_stats['hits'] - initial_cache_hits
|
||||
cache_misses = self.cache_stats['misses'] - initial_cache_misses
|
||||
memory_delta = end_memory - start_memory if start_memory and end_memory else None
|
||||
|
||||
metric = PerformanceMetric(
|
||||
operation=operation_name,
|
||||
duration=duration,
|
||||
query_count=query_count,
|
||||
cache_hits=cache_hits,
|
||||
cache_misses=cache_misses,
|
||||
memory_usage=memory_delta,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
self.metrics.append(metric)
|
||||
self._log_metric(metric)
|
||||
|
||||
def _get_memory_usage(self) -> Optional[float]:
|
||||
"""Get current memory usage in MB."""
|
||||
try:
|
||||
import psutil
|
||||
process = psutil.Process()
|
||||
return process.memory_info().rss / 1024 / 1024 # Convert to MB
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def _log_metric(self, metric: PerformanceMetric):
|
||||
"""Log performance metric with appropriate level."""
|
||||
message = (
|
||||
f"{metric.operation}: {metric.duration:.3f}s, "
|
||||
f"{metric.query_count} queries, "
|
||||
f"{metric.cache_hits} cache hits"
|
||||
)
|
||||
|
||||
if metric.memory_usage:
|
||||
message += f", {metric.memory_usage:.2f}MB memory delta"
|
||||
|
||||
# Log as warning if performance is concerning
|
||||
if metric.duration > 1.0 or metric.query_count > 10:
|
||||
logger.warning(f"Performance concern: {message}")
|
||||
else:
|
||||
logger.info(f"Performance metric: {message}")
|
||||
|
||||
def get_performance_summary(self) -> Dict[str, Any]:
|
||||
"""Get summary of all performance metrics."""
|
||||
if not self.metrics:
|
||||
return {'message': 'No metrics collected'}
|
||||
|
||||
durations = [m.duration for m in self.metrics]
|
||||
query_counts = [m.query_count for m in self.metrics]
|
||||
|
||||
return {
|
||||
'total_operations': len(self.metrics),
|
||||
'duration_stats': {
|
||||
'mean': statistics.mean(durations),
|
||||
'median': statistics.median(durations),
|
||||
'min': min(durations),
|
||||
'max': max(durations),
|
||||
'total': sum(durations)
|
||||
},
|
||||
'query_stats': {
|
||||
'mean': statistics.mean(query_counts),
|
||||
'median': statistics.median(query_counts),
|
||||
'min': min(query_counts),
|
||||
'max': max(query_counts),
|
||||
'total': sum(query_counts)
|
||||
},
|
||||
'cache_stats': {
|
||||
'total_hits': sum(m.cache_hits for m in self.metrics),
|
||||
'total_misses': sum(m.cache_misses for m in self.metrics),
|
||||
'hit_rate': self._calculate_cache_hit_rate()
|
||||
},
|
||||
'slowest_operations': self._get_slowest_operations(5),
|
||||
'most_query_intensive': self._get_most_query_intensive(5)
|
||||
}
|
||||
|
||||
def _calculate_cache_hit_rate(self) -> float:
|
||||
"""Calculate overall cache hit rate."""
|
||||
total_hits = sum(m.cache_hits for m in self.metrics)
|
||||
total_requests = total_hits + sum(m.cache_misses for m in self.metrics)
|
||||
return (total_hits / total_requests * 100) if total_requests > 0 else 0.0
|
||||
|
||||
def _get_slowest_operations(self, count: int) -> List[Dict[str, Any]]:
|
||||
"""Get the slowest operations."""
|
||||
sorted_metrics = sorted(self.metrics, key=lambda m: m.duration, reverse=True)
|
||||
return [
|
||||
{
|
||||
'operation': m.operation,
|
||||
'duration': m.duration,
|
||||
'query_count': m.query_count,
|
||||
'timestamp': m.timestamp.isoformat()
|
||||
}
|
||||
for m in sorted_metrics[:count]
|
||||
]
|
||||
|
||||
def _get_most_query_intensive(self, count: int) -> List[Dict[str, Any]]:
|
||||
"""Get operations with the most database queries."""
|
||||
sorted_metrics = sorted(self.metrics, key=lambda m: m.query_count, reverse=True)
|
||||
return [
|
||||
{
|
||||
'operation': m.operation,
|
||||
'query_count': m.query_count,
|
||||
'duration': m.duration,
|
||||
'timestamp': m.timestamp.isoformat()
|
||||
}
|
||||
for m in sorted_metrics[:count]
|
||||
]
|
||||
|
||||
|
||||
class BenchmarkSuite:
|
||||
"""
|
||||
Comprehensive benchmarking suite for park listing performance.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.monitor = PerformanceMonitor()
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def run_autocomplete_benchmark(self, queries: List[str] = None) -> Dict[str, Any]:
|
||||
"""Benchmark autocomplete performance with various queries."""
|
||||
if not queries:
|
||||
queries = [
|
||||
'Di', # Short query
|
||||
'Disney', # Common brand
|
||||
'Universal', # Another common brand
|
||||
'Cedar Point', # Specific park
|
||||
'California', # Location
|
||||
'Roller', # Generic term
|
||||
'Xyz123' # Non-existent query
|
||||
]
|
||||
|
||||
results = []
|
||||
|
||||
for query in queries:
|
||||
with self.monitor.measure_operation(f"autocomplete_{query}", query=query):
|
||||
# Simulate autocomplete request
|
||||
from apps.parks.views_autocomplete import ParkAutocompleteView
|
||||
|
||||
request = self.factory.get(f'/api/parks/autocomplete/?q={query}')
|
||||
view = ParkAutocompleteView()
|
||||
response = view.get(request)
|
||||
|
||||
results.append({
|
||||
'query': query,
|
||||
'status_code': response.status_code,
|
||||
'response_time': self.monitor.metrics[-1].duration,
|
||||
'query_count': self.monitor.metrics[-1].query_count
|
||||
})
|
||||
|
||||
return {
|
||||
'benchmark_type': 'autocomplete',
|
||||
'queries_tested': len(queries),
|
||||
'results': results,
|
||||
'summary': self.monitor.get_performance_summary()
|
||||
}
|
||||
|
||||
def run_listing_benchmark(self, scenarios: List[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Benchmark park listing performance with various filter scenarios."""
|
||||
if not scenarios:
|
||||
scenarios = [
|
||||
{'name': 'no_filters', 'params': {}},
|
||||
{'name': 'status_filter', 'params': {'status': 'OPERATING'}},
|
||||
{'name': 'operator_filter', 'params': {'operator': 'Disney'}},
|
||||
{'name': 'location_filter', 'params': {'country': 'United States'}},
|
||||
{'name': 'complex_filter', 'params': {
|
||||
'status': 'OPERATING',
|
||||
'has_coasters': 'true',
|
||||
'min_rating': '4.0'
|
||||
}},
|
||||
{'name': 'search_query', 'params': {'search': 'Magic Kingdom'}},
|
||||
{'name': 'pagination_last_page', 'params': {'page': '10'}}
|
||||
]
|
||||
|
||||
results = []
|
||||
|
||||
for scenario in scenarios:
|
||||
with self.monitor.measure_operation(f"listing_{scenario['name']}", **scenario['params']):
|
||||
# Simulate listing request
|
||||
from apps.parks.views import ParkListView
|
||||
|
||||
query_string = '&'.join([f"{k}={v}" for k, v in scenario['params'].items()])
|
||||
request = self.factory.get(f'/parks/?{query_string}')
|
||||
|
||||
view = ParkListView()
|
||||
view.setup(request)
|
||||
|
||||
# Simulate getting the queryset and context
|
||||
queryset = view.get_queryset()
|
||||
context = view.get_context_data()
|
||||
|
||||
results.append({
|
||||
'scenario': scenario['name'],
|
||||
'params': scenario['params'],
|
||||
'result_count': queryset.count() if hasattr(queryset, 'count') else len(queryset),
|
||||
'response_time': self.monitor.metrics[-1].duration,
|
||||
'query_count': self.monitor.metrics[-1].query_count
|
||||
})
|
||||
|
||||
return {
|
||||
'benchmark_type': 'listing',
|
||||
'scenarios_tested': len(scenarios),
|
||||
'results': results,
|
||||
'summary': self.monitor.get_performance_summary()
|
||||
}
|
||||
|
||||
def run_pagination_benchmark(self, page_sizes: List[int] = None, page_numbers: List[int] = None) -> Dict[str, Any]:
|
||||
"""Benchmark pagination performance with different page sizes and numbers."""
|
||||
if not page_sizes:
|
||||
page_sizes = [10, 20, 50, 100]
|
||||
if not page_numbers:
|
||||
page_numbers = [1, 5, 10, 50]
|
||||
|
||||
results = []
|
||||
|
||||
for page_size in page_sizes:
|
||||
for page_number in page_numbers:
|
||||
scenario_name = f"page_{page_number}_size_{page_size}"
|
||||
|
||||
with self.monitor.measure_operation(scenario_name, page_size=page_size, page_number=page_number):
|
||||
from apps.parks.services.pagination_service import get_optimized_page
|
||||
from apps.parks.querysets import get_base_park_queryset
|
||||
|
||||
queryset = get_base_park_queryset()
|
||||
page, metadata = get_optimized_page(queryset, page_number, page_size)
|
||||
|
||||
results.append({
|
||||
'page_size': page_size,
|
||||
'page_number': page_number,
|
||||
'total_count': metadata.get('total_count', 0),
|
||||
'response_time': self.monitor.metrics[-1].duration,
|
||||
'query_count': self.monitor.metrics[-1].query_count
|
||||
})
|
||||
|
||||
return {
|
||||
'benchmark_type': 'pagination',
|
||||
'configurations_tested': len(results),
|
||||
'results': results,
|
||||
'summary': self.monitor.get_performance_summary()
|
||||
}
|
||||
|
||||
def run_full_benchmark_suite(self) -> Dict[str, Any]:
|
||||
"""Run the complete benchmark suite."""
|
||||
logger.info("Starting comprehensive benchmark suite")
|
||||
|
||||
suite_start = time.perf_counter()
|
||||
|
||||
# Run all benchmarks
|
||||
autocomplete_results = self.run_autocomplete_benchmark()
|
||||
listing_results = self.run_listing_benchmark()
|
||||
pagination_results = self.run_pagination_benchmark()
|
||||
|
||||
suite_duration = time.perf_counter() - suite_start
|
||||
|
||||
# Generate comprehensive report
|
||||
report = {
|
||||
'benchmark_suite': 'Park Listing Performance',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'total_duration': suite_duration,
|
||||
'autocomplete': autocomplete_results,
|
||||
'listing': listing_results,
|
||||
'pagination': pagination_results,
|
||||
'overall_summary': self.monitor.get_performance_summary(),
|
||||
'recommendations': self._generate_recommendations()
|
||||
}
|
||||
|
||||
# Save report
|
||||
self._save_benchmark_report(report)
|
||||
|
||||
logger.info(f"Benchmark suite completed in {suite_duration:.3f}s")
|
||||
|
||||
return report
|
||||
|
||||
def _generate_recommendations(self) -> List[str]:
|
||||
"""Generate performance recommendations based on benchmark results."""
|
||||
recommendations = []
|
||||
summary = self.monitor.get_performance_summary()
|
||||
|
||||
# Check average response times
|
||||
if summary['duration_stats']['mean'] > 0.5:
|
||||
recommendations.append("Average response time is high (>500ms). Consider implementing additional caching.")
|
||||
|
||||
# Check query counts
|
||||
if summary['query_stats']['mean'] > 5:
|
||||
recommendations.append("High average query count. Review and optimize database queries.")
|
||||
|
||||
# Check cache hit rate
|
||||
if summary['cache_stats']['hit_rate'] < 80:
|
||||
recommendations.append("Cache hit rate is low (<80%). Increase cache timeouts or improve cache key strategy.")
|
||||
|
||||
# Check for slow operations
|
||||
slowest = summary.get('slowest_operations', [])
|
||||
if slowest and slowest[0]['duration'] > 2.0:
|
||||
recommendations.append(f"Slowest operation ({slowest[0]['operation']}) is very slow (>{slowest[0]['duration']:.2f}s).")
|
||||
|
||||
if not recommendations:
|
||||
recommendations.append("Performance appears to be within acceptable ranges.")
|
||||
|
||||
return recommendations
|
||||
|
||||
def _save_benchmark_report(self, report: Dict[str, Any]):
|
||||
"""Save benchmark report to file and cache."""
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"benchmark_report_{timestamp}.json"
|
||||
|
||||
try:
|
||||
# Save to logs directory
|
||||
import os
|
||||
logs_dir = "logs"
|
||||
os.makedirs(logs_dir, exist_ok=True)
|
||||
|
||||
filepath = os.path.join(logs_dir, filename)
|
||||
with open(filepath, 'w') as f:
|
||||
json.dump(report, f, indent=2, default=str)
|
||||
|
||||
logger.info(f"Benchmark report saved to {filepath}")
|
||||
|
||||
# Also cache the report
|
||||
cache.set(f"benchmark_report_latest", report, 3600) # 1 hour
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving benchmark report: {e}")
|
||||
|
||||
|
||||
# Global performance monitor instance
|
||||
performance_monitor = PerformanceMonitor()
|
||||
|
||||
|
||||
def benchmark_operation(operation_name: str):
|
||||
"""Decorator to benchmark a function."""
|
||||
def decorator(func: Callable):
|
||||
def wrapper(*args, **kwargs):
|
||||
with performance_monitor.measure_operation(operation_name):
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
# Convenience function to run benchmarks
|
||||
def run_performance_benchmark():
|
||||
"""Run the complete performance benchmark suite."""
|
||||
suite = BenchmarkSuite()
|
||||
return suite.run_full_benchmark_suite()
|
||||
@@ -1,363 +0,0 @@
|
||||
/* Performance-optimized CSS for park listing page */
|
||||
|
||||
/* Critical CSS that should be inlined */
|
||||
.park-listing {
|
||||
/* Use GPU acceleration for smooth animations */
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* Lazy loading image styles */
|
||||
img[data-src] {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
img.loading {
|
||||
opacity: 0.7;
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
img.loaded {
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
img.error {
|
||||
background: #f5f5f5;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Optimized grid layout using CSS Grid */
|
||||
.park-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
/* Use containment for better performance */
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.park-card {
|
||||
/* Optimize for animations */
|
||||
will-change: transform, box-shadow;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
/* Enable GPU acceleration */
|
||||
transform: translateZ(0);
|
||||
/* Optimize paint */
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
.park-card:hover {
|
||||
transform: translateY(-4px) translateZ(0);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Efficient loading states */
|
||||
.loading {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.4),
|
||||
transparent
|
||||
);
|
||||
animation: loading-sweep 1.5s infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes loading-sweep {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Optimized autocomplete dropdown */
|
||||
.autocomplete-suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
/* Hide by default */
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.2s ease;
|
||||
/* Optimize scrolling */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.autocomplete-suggestions.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.suggestion-item:hover,
|
||||
.suggestion-item.active {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.suggestion-icon {
|
||||
margin-right: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.suggestion-name {
|
||||
font-weight: 500;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.suggestion-details {
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Optimized filter panel */
|
||||
.filter-panel {
|
||||
/* Use flexbox for efficient layout */
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
/* Optimize for frequent updates */
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.filter-input:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
/* Performance-optimized pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 2rem 0;
|
||||
/* Optimize for position changes */
|
||||
contain: layout;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
/* Optimize for hover effects */
|
||||
will-change: background-color, border-color;
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(.disabled) {
|
||||
background: #f8f9fa;
|
||||
border-color: #bbb;
|
||||
}
|
||||
|
||||
.pagination-btn.active {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.pagination-btn.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive optimizations */
|
||||
@media (max-width: 768px) {
|
||||
.park-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* High DPI optimizations */
|
||||
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
|
||||
.park-card img {
|
||||
/* Use higher quality images on retina displays */
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduce motion for accessibility */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Performance debugging styles (only in development) */
|
||||
.debug-metrics {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
z-index: 9999;
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.debug .debug-metrics {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.debug-metrics span {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Print optimizations */
|
||||
@media print {
|
||||
.autocomplete-suggestions,
|
||||
.filter-panel,
|
||||
.pagination,
|
||||
.debug-metrics {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.park-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.park-card {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
/* Container queries for better responsive design */
|
||||
@container (max-width: 400px) {
|
||||
.park-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.park-card img {
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus management for better accessibility */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 6px;
|
||||
background: #000;
|
||||
color: white;
|
||||
padding: 8px;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 6px;
|
||||
}
|
||||
|
||||
/* Efficient animations using transform and opacity only */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Optimize for critical rendering path */
|
||||
.above-fold {
|
||||
/* Ensure critical content renders first */
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
.below-fold {
|
||||
/* Defer non-critical content */
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 500px;
|
||||
}
|
||||
@@ -1,518 +0,0 @@
|
||||
/**
|
||||
* Performance-optimized JavaScript for park listing page
|
||||
* Implements lazy loading, debouncing, and efficient DOM manipulation
|
||||
*/
|
||||
|
||||
class ParkListingPerformance {
|
||||
constructor() {
|
||||
this.searchTimeout = null;
|
||||
this.lastScrollPosition = 0;
|
||||
this.observerOptions = {
|
||||
root: null,
|
||||
rootMargin: '50px',
|
||||
threshold: 0.1
|
||||
};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupLazyLoading();
|
||||
this.setupDebouncedSearch();
|
||||
this.setupOptimizedFiltering();
|
||||
this.setupProgressiveImageLoading();
|
||||
this.setupPerformanceMonitoring();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup lazy loading for park images using Intersection Observer
|
||||
*/
|
||||
setupLazyLoading() {
|
||||
if ('IntersectionObserver' in window) {
|
||||
this.imageObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
this.loadImage(entry.target);
|
||||
this.imageObserver.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, this.observerOptions);
|
||||
|
||||
// Observe all lazy images
|
||||
document.querySelectorAll('img[data-src]').forEach(img => {
|
||||
this.imageObserver.observe(img);
|
||||
});
|
||||
} else {
|
||||
// Fallback for browsers without Intersection Observer
|
||||
this.loadAllImages();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load individual image with error handling and placeholder
|
||||
*/
|
||||
loadImage(img) {
|
||||
const src = img.dataset.src;
|
||||
const placeholder = img.dataset.placeholder;
|
||||
|
||||
// Start with low quality placeholder
|
||||
if (placeholder && !img.src) {
|
||||
img.src = placeholder;
|
||||
img.classList.add('loading');
|
||||
}
|
||||
|
||||
// Load high quality image
|
||||
const highQualityImg = new Image();
|
||||
highQualityImg.onload = () => {
|
||||
img.src = highQualityImg.src;
|
||||
img.classList.remove('loading');
|
||||
img.classList.add('loaded');
|
||||
};
|
||||
|
||||
highQualityImg.onerror = () => {
|
||||
img.src = '/static/images/placeholders/park-placeholder.jpg';
|
||||
img.classList.add('error');
|
||||
};
|
||||
|
||||
highQualityImg.src = src;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all images (fallback for older browsers)
|
||||
*/
|
||||
loadAllImages() {
|
||||
document.querySelectorAll('img[data-src]').forEach(img => {
|
||||
this.loadImage(img);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup debounced search to reduce API calls
|
||||
*/
|
||||
setupDebouncedSearch() {
|
||||
const searchInput = document.querySelector('[data-autocomplete]');
|
||||
if (!searchInput) return;
|
||||
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
clearTimeout(this.searchTimeout);
|
||||
|
||||
const query = e.target.value.trim();
|
||||
|
||||
if (query.length < 2) {
|
||||
this.hideSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce search requests
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.performSearch(query);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Handle keyboard navigation
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
this.handleSearchKeyboard(e);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform optimized search with caching
|
||||
*/
|
||||
async performSearch(query) {
|
||||
const cacheKey = `search_${query.toLowerCase()}`;
|
||||
|
||||
// Check session storage for cached results
|
||||
const cached = sessionStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
const results = JSON.parse(cached);
|
||||
this.displaySuggestions(results);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/parks/autocomplete/?q=${encodeURIComponent(query)}`, {
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Cache results for session
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify(data));
|
||||
|
||||
this.displaySuggestions(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
this.hideSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display search suggestions with efficient DOM manipulation
|
||||
*/
|
||||
displaySuggestions(data) {
|
||||
const container = document.querySelector('[data-suggestions]');
|
||||
if (!container) return;
|
||||
|
||||
// Use document fragment for efficient DOM updates
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
if (data.suggestions && data.suggestions.length > 0) {
|
||||
data.suggestions.forEach(suggestion => {
|
||||
const item = this.createSuggestionItem(suggestion);
|
||||
fragment.appendChild(item);
|
||||
});
|
||||
} else {
|
||||
const noResults = document.createElement('div');
|
||||
noResults.className = 'no-results';
|
||||
noResults.textContent = 'No suggestions found';
|
||||
fragment.appendChild(noResults);
|
||||
}
|
||||
|
||||
// Replace content efficiently
|
||||
container.innerHTML = '';
|
||||
container.appendChild(fragment);
|
||||
container.classList.add('visible');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create suggestion item element
|
||||
*/
|
||||
createSuggestionItem(suggestion) {
|
||||
const item = document.createElement('div');
|
||||
item.className = `suggestion-item suggestion-${suggestion.type}`;
|
||||
|
||||
const icon = this.getSuggestionIcon(suggestion.type);
|
||||
const details = suggestion.operator ? ` • ${suggestion.operator}` :
|
||||
suggestion.park_count ? ` • ${suggestion.park_count} parks` : '';
|
||||
|
||||
item.innerHTML = `
|
||||
<span class="suggestion-icon">${icon}</span>
|
||||
<span class="suggestion-name">${this.escapeHtml(suggestion.name)}</span>
|
||||
<span class="suggestion-details">${details}</span>
|
||||
`;
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
this.selectSuggestion(suggestion);
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon for suggestion type
|
||||
*/
|
||||
getSuggestionIcon(type) {
|
||||
const icons = {
|
||||
park: '🏰',
|
||||
operator: '🏢',
|
||||
location: '📍'
|
||||
};
|
||||
return icons[type] || '🔍';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle suggestion selection
|
||||
*/
|
||||
selectSuggestion(suggestion) {
|
||||
const searchInput = document.querySelector('[data-autocomplete]');
|
||||
if (searchInput) {
|
||||
searchInput.value = suggestion.name;
|
||||
|
||||
// Trigger search or navigation
|
||||
if (suggestion.url) {
|
||||
window.location.href = suggestion.url;
|
||||
} else {
|
||||
// Trigger filter update
|
||||
this.updateFilters({ search: suggestion.name });
|
||||
}
|
||||
}
|
||||
|
||||
this.hideSuggestions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide suggestions dropdown
|
||||
*/
|
||||
hideSuggestions() {
|
||||
const container = document.querySelector('[data-suggestions]');
|
||||
if (container) {
|
||||
container.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup optimized filtering with minimal reflows
|
||||
*/
|
||||
setupOptimizedFiltering() {
|
||||
const filterForm = document.querySelector('[data-filter-form]');
|
||||
if (!filterForm) return;
|
||||
|
||||
// Debounce filter changes
|
||||
filterForm.addEventListener('change', (e) => {
|
||||
clearTimeout(this.filterTimeout);
|
||||
|
||||
this.filterTimeout = setTimeout(() => {
|
||||
this.updateFilters();
|
||||
}, 150);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update filters using HTMX with loading states
|
||||
*/
|
||||
updateFilters(extraParams = {}) {
|
||||
const form = document.querySelector('[data-filter-form]');
|
||||
const resultsContainer = document.querySelector('[data-results]');
|
||||
|
||||
if (!form || !resultsContainer) return;
|
||||
|
||||
// Show loading state
|
||||
resultsContainer.classList.add('loading');
|
||||
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Add extra parameters
|
||||
Object.entries(extraParams).forEach(([key, value]) => {
|
||||
formData.set(key, value);
|
||||
});
|
||||
|
||||
// Use HTMX for efficient partial updates
|
||||
if (window.htmx) {
|
||||
htmx.ajax('GET', form.action + '?' + new URLSearchParams(formData), {
|
||||
target: '[data-results]',
|
||||
swap: 'innerHTML'
|
||||
}).then(() => {
|
||||
resultsContainer.classList.remove('loading');
|
||||
this.setupLazyLoading(); // Re-initialize for new content
|
||||
this.updatePerformanceMetrics();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup progressive image loading with CloudFlare optimization
|
||||
*/
|
||||
setupProgressiveImageLoading() {
|
||||
// Use CloudFlare's automatic image optimization
|
||||
document.querySelectorAll('img[data-cf-image]').forEach(img => {
|
||||
const imageId = img.dataset.cfImage;
|
||||
const width = img.dataset.width || 400;
|
||||
|
||||
// Start with low quality
|
||||
img.src = this.getCloudFlareImageUrl(imageId, width, 'low');
|
||||
|
||||
// Load high quality when in viewport
|
||||
if (this.imageObserver) {
|
||||
this.imageObserver.observe(img);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get optimized CloudFlare image URL
|
||||
*/
|
||||
getCloudFlareImageUrl(imageId, width, quality = 'high') {
|
||||
const baseUrl = window.CLOUDFLARE_IMAGES_BASE_URL || '/images';
|
||||
const qualityMap = {
|
||||
low: 20,
|
||||
medium: 60,
|
||||
high: 85
|
||||
};
|
||||
|
||||
return `${baseUrl}/${imageId}/w=${width},quality=${qualityMap[quality]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup performance monitoring
|
||||
*/
|
||||
setupPerformanceMonitoring() {
|
||||
// Track page load performance
|
||||
if ('performance' in window) {
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
this.reportPerformanceMetrics();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
// Track user interactions
|
||||
this.setupInteractionTracking();
|
||||
}
|
||||
|
||||
/**
|
||||
* Report performance metrics
|
||||
*/
|
||||
reportPerformanceMetrics() {
|
||||
if (!('performance' in window)) return;
|
||||
|
||||
const navigation = performance.getEntriesByType('navigation')[0];
|
||||
const paint = performance.getEntriesByType('paint');
|
||||
|
||||
const metrics = {
|
||||
loadTime: navigation.loadEventEnd - navigation.loadEventStart,
|
||||
domContentLoaded: navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart,
|
||||
firstPaint: paint.find(p => p.name === 'first-paint')?.startTime || 0,
|
||||
firstContentfulPaint: paint.find(p => p.name === 'first-contentful-paint')?.startTime || 0,
|
||||
timestamp: Date.now(),
|
||||
page: 'park-listing'
|
||||
};
|
||||
|
||||
// Send metrics to analytics (if configured)
|
||||
this.sendAnalytics('performance', metrics);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup interaction tracking for performance insights
|
||||
*/
|
||||
setupInteractionTracking() {
|
||||
const startTime = performance.now();
|
||||
|
||||
['click', 'input', 'scroll'].forEach(eventType => {
|
||||
document.addEventListener(eventType, (e) => {
|
||||
this.trackInteraction(eventType, e.target, performance.now() - startTime);
|
||||
}, { passive: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track user interactions
|
||||
*/
|
||||
trackInteraction(type, target, time) {
|
||||
// Throttle interaction tracking
|
||||
if (!this.lastInteractionTime || time - this.lastInteractionTime > 100) {
|
||||
this.lastInteractionTime = time;
|
||||
|
||||
const interaction = {
|
||||
type,
|
||||
element: target.tagName.toLowerCase(),
|
||||
class: target.className,
|
||||
time: Math.round(time),
|
||||
page: 'park-listing'
|
||||
};
|
||||
|
||||
this.sendAnalytics('interaction', interaction);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send analytics data
|
||||
*/
|
||||
sendAnalytics(event, data) {
|
||||
// Only send in production and if analytics is configured
|
||||
if (window.ENABLE_ANALYTICS && navigator.sendBeacon) {
|
||||
const payload = JSON.stringify({
|
||||
event,
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
url: window.location.pathname
|
||||
});
|
||||
|
||||
navigator.sendBeacon('/api/analytics/', payload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update performance metrics display
|
||||
*/
|
||||
updatePerformanceMetrics() {
|
||||
const metricsDisplay = document.querySelector('[data-performance-metrics]');
|
||||
if (!metricsDisplay || !window.SHOW_DEBUG) return;
|
||||
|
||||
const imageCount = document.querySelectorAll('img').length;
|
||||
const loadedImages = document.querySelectorAll('img.loaded').length;
|
||||
const cacheHits = Object.keys(sessionStorage).filter(k => k.startsWith('search_')).length;
|
||||
|
||||
metricsDisplay.innerHTML = `
|
||||
<div class="debug-metrics">
|
||||
<span>Images: ${loadedImages}/${imageCount}</span>
|
||||
<span>Cache hits: ${cacheHits}</span>
|
||||
<span>Memory: ${this.getMemoryUsage()}MB</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get approximate memory usage
|
||||
*/
|
||||
getMemoryUsage() {
|
||||
if ('memory' in performance) {
|
||||
return Math.round(performance.memory.usedJSHeapSize / 1024 / 1024);
|
||||
}
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard navigation in search
|
||||
*/
|
||||
handleSearchKeyboard(e) {
|
||||
const suggestions = document.querySelectorAll('.suggestion-item');
|
||||
const active = document.querySelector('.suggestion-item.active');
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
this.navigateSuggestions(suggestions, active, 1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
this.navigateSuggestions(suggestions, active, -1);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (active) {
|
||||
active.click();
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
this.hideSuggestions();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate through suggestions with keyboard
|
||||
*/
|
||||
navigateSuggestions(suggestions, active, direction) {
|
||||
if (active) {
|
||||
active.classList.remove('active');
|
||||
}
|
||||
|
||||
let index = active ? Array.from(suggestions).indexOf(active) : -1;
|
||||
index += direction;
|
||||
|
||||
if (index < 0) index = suggestions.length - 1;
|
||||
if (index >= suggestions.length) index = 0;
|
||||
|
||||
if (suggestions[index]) {
|
||||
suggestions[index].classList.add('active');
|
||||
suggestions[index].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to escape HTML
|
||||
*/
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize performance optimizations when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new ParkListingPerformance();
|
||||
});
|
||||
} else {
|
||||
new ParkListingPerformance();
|
||||
}
|
||||
|
||||
// Export for testing
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = ParkListingPerformance;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
{% load static %}
|
||||
{% load cotton %}
|
||||
|
||||
{% if error %}
|
||||
<div class="p-4" data-testid="park-list-error">
|
||||
<div class="inline-flex items-center px-4 py-2 rounded-md bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 border border-red-200 dark:border-red-800">
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% for park in object_list|default:parks %}
|
||||
<c-park_card park=park view_mode=view_mode />
|
||||
{% empty %}
|
||||
<div class="{% if view_mode == 'list' %}w-full{% else %}col-span-full{% endif %} p-12 text-center" data-testid="no-parks-found">
|
||||
<div class="mx-auto w-24 h-24 text-gray-300 dark:text-gray-600 mb-6">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-full h-full">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">No parks found</h3>
|
||||
<div class="text-gray-600 dark:text-gray-400">
|
||||
{% if search_query %}
|
||||
<p class="mb-4">No parks found matching "{{ search_query }}". Try adjusting your search terms.</p>
|
||||
{% else %}
|
||||
<p class="mb-4">No parks found matching your criteria. Try adjusting your filters.</p>
|
||||
{% endif %}
|
||||
{% if user.is_authenticated %}
|
||||
<p>You can also <a href="{% url 'parks:park_create' %}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 font-semibold">add a new park</a>.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
@@ -1,178 +0,0 @@
|
||||
"""
|
||||
Park search autocomplete views for enhanced search functionality.
|
||||
Provides fast, cached autocomplete suggestions for park search.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any
|
||||
from django.http import JsonResponse
|
||||
from django.views import View
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Q
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_page
|
||||
|
||||
from .models import Park
|
||||
from .models.companies import Company
|
||||
from .services.filter_service import ParkFilterService
|
||||
|
||||
|
||||
class ParkAutocompleteView(View):
|
||||
"""
|
||||
Provides autocomplete suggestions for park search.
|
||||
Returns JSON with park names, operators, and location suggestions.
|
||||
"""
|
||||
|
||||
def get(self, request):
|
||||
"""Handle GET request for autocomplete suggestions."""
|
||||
query = request.GET.get('q', '').strip()
|
||||
|
||||
if len(query) < 2:
|
||||
return JsonResponse({
|
||||
'suggestions': [],
|
||||
'message': 'Type at least 2 characters to search'
|
||||
})
|
||||
|
||||
# Check cache first
|
||||
cache_key = f"park_autocomplete:{query.lower()}"
|
||||
cached_result = cache.get(cache_key)
|
||||
|
||||
if cached_result:
|
||||
return JsonResponse(cached_result)
|
||||
|
||||
# Generate suggestions
|
||||
suggestions = self._get_suggestions(query)
|
||||
|
||||
# Cache results for 5 minutes
|
||||
result = {
|
||||
'suggestions': suggestions,
|
||||
'query': query
|
||||
}
|
||||
cache.set(cache_key, result, 300)
|
||||
|
||||
return JsonResponse(result)
|
||||
|
||||
def _get_suggestions(self, query: str) -> List[Dict[str, Any]]:
|
||||
"""Generate autocomplete suggestions based on query."""
|
||||
suggestions = []
|
||||
|
||||
# Park name suggestions (top 5)
|
||||
park_suggestions = self._get_park_suggestions(query)
|
||||
suggestions.extend(park_suggestions)
|
||||
|
||||
# Operator suggestions (top 3)
|
||||
operator_suggestions = self._get_operator_suggestions(query)
|
||||
suggestions.extend(operator_suggestions)
|
||||
|
||||
# Location suggestions (top 3)
|
||||
location_suggestions = self._get_location_suggestions(query)
|
||||
suggestions.extend(location_suggestions)
|
||||
|
||||
# Remove duplicates and limit results
|
||||
seen = set()
|
||||
unique_suggestions = []
|
||||
for suggestion in suggestions:
|
||||
key = suggestion['name'].lower()
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique_suggestions.append(suggestion)
|
||||
|
||||
return unique_suggestions[:10] # Limit to 10 suggestions
|
||||
|
||||
def _get_park_suggestions(self, query: str) -> List[Dict[str, Any]]:
|
||||
"""Get park name suggestions."""
|
||||
parks = Park.objects.filter(
|
||||
name__icontains=query,
|
||||
status='OPERATING'
|
||||
).select_related('operator').order_by('name')[:5]
|
||||
|
||||
suggestions = []
|
||||
for park in parks:
|
||||
suggestion = {
|
||||
'name': park.name,
|
||||
'type': 'park',
|
||||
'operator': park.operator.name if park.operator else None,
|
||||
'url': f'/parks/{park.slug}/' if park.slug else None
|
||||
}
|
||||
suggestions.append(suggestion)
|
||||
|
||||
return suggestions
|
||||
|
||||
def _get_operator_suggestions(self, query: str) -> List[Dict[str, Any]]:
|
||||
"""Get operator suggestions."""
|
||||
operators = Company.objects.filter(
|
||||
roles__contains=['OPERATOR'],
|
||||
name__icontains=query
|
||||
).order_by('name')[:3]
|
||||
|
||||
suggestions = []
|
||||
for operator in operators:
|
||||
suggestion = {
|
||||
'name': operator.name,
|
||||
'type': 'operator',
|
||||
'park_count': operator.operated_parks.filter(status='OPERATING').count()
|
||||
}
|
||||
suggestions.append(suggestion)
|
||||
|
||||
return suggestions
|
||||
|
||||
def _get_location_suggestions(self, query: str) -> List[Dict[str, Any]]:
|
||||
"""Get location (city/country) suggestions."""
|
||||
# Get unique cities
|
||||
city_parks = Park.objects.filter(
|
||||
location__city__icontains=query,
|
||||
status='OPERATING'
|
||||
).select_related('location').order_by('location__city').distinct()[:2]
|
||||
|
||||
# Get unique countries
|
||||
country_parks = Park.objects.filter(
|
||||
location__country__icontains=query,
|
||||
status='OPERATING'
|
||||
).select_related('location').order_by('location__country').distinct()[:2]
|
||||
|
||||
suggestions = []
|
||||
|
||||
# Add city suggestions
|
||||
for park in city_parks:
|
||||
if park.location and park.location.city:
|
||||
city_name = park.location.city
|
||||
if park.location.country:
|
||||
city_name += f", {park.location.country}"
|
||||
|
||||
suggestion = {
|
||||
'name': city_name,
|
||||
'type': 'location',
|
||||
'location_type': 'city'
|
||||
}
|
||||
suggestions.append(suggestion)
|
||||
|
||||
# Add country suggestions
|
||||
for park in country_parks:
|
||||
if park.location and park.location.country:
|
||||
suggestion = {
|
||||
'name': park.location.country,
|
||||
'type': 'location',
|
||||
'location_type': 'country'
|
||||
}
|
||||
suggestions.append(suggestion)
|
||||
|
||||
return suggestions
|
||||
|
||||
|
||||
@method_decorator(cache_page(60 * 5), name='dispatch') # Cache for 5 minutes
|
||||
class QuickFilterSuggestionsView(View):
|
||||
"""
|
||||
Provides quick filter suggestions and popular filters.
|
||||
Used for search dropdown quick actions.
|
||||
"""
|
||||
|
||||
def get(self, request):
|
||||
"""Handle GET request for quick filter suggestions."""
|
||||
filter_service = ParkFilterService()
|
||||
popular_filters = filter_service.get_popular_filters()
|
||||
filter_counts = filter_service.get_filter_counts()
|
||||
|
||||
return JsonResponse({
|
||||
'quick_filters': popular_filters.get('quick_filters', []),
|
||||
'filter_counts': filter_counts,
|
||||
'recommended_sorts': popular_filters.get('recommended_sorts', [])
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
48
backend/.env.example
Normal file
48
backend/.env.example
Normal 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)
|
||||
229
backend/README.md
Normal file
229
backend/README.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# ThrillWiki Backend
|
||||
|
||||
Django REST API backend for the ThrillWiki monorepo.
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
This backend follows Django best practices with a modular app structure:
|
||||
|
||||
```
|
||||
backend/
|
||||
├── apps/ # Django applications
|
||||
│ ├── accounts/ # User management
|
||||
│ ├── parks/ # Theme park data
|
||||
│ ├── rides/ # Ride information
|
||||
│ ├── moderation/ # Content moderation
|
||||
│ ├── location/ # Geographic data
|
||||
│ ├── media/ # File management
|
||||
│ ├── email_service/ # Email functionality
|
||||
│ └── core/ # Core utilities
|
||||
├── config/ # Django configuration
|
||||
│ ├── django/ # Settings files
|
||||
│ └── settings/ # Modular settings
|
||||
├── templates/ # Django templates
|
||||
├── static/ # Static files
|
||||
└── tests/ # Test files
|
||||
```
|
||||
|
||||
## 🛠️ Technology Stack
|
||||
|
||||
- **Django 5.0+** - Web framework
|
||||
- **Django REST Framework** - API framework
|
||||
- **PostgreSQL** - Primary database
|
||||
- **Redis** - Caching and sessions
|
||||
- **UV** - Python package management
|
||||
- **Celery** - Background task processing
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.11+
|
||||
- [uv](https://docs.astral.sh/uv/) package manager
|
||||
- PostgreSQL 14+
|
||||
- Redis 6+
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Install dependencies**
|
||||
```bash
|
||||
cd backend
|
||||
uv sync
|
||||
```
|
||||
|
||||
2. **Environment configuration**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
```
|
||||
|
||||
3. **Database setup**
|
||||
```bash
|
||||
uv run manage.py migrate
|
||||
uv run manage.py createsuperuser
|
||||
```
|
||||
|
||||
4. **Start development server**
|
||||
```bash
|
||||
uv run manage.py runserver
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Required environment variables:
|
||||
|
||||
```bash
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
|
||||
|
||||
# Django
|
||||
SECRET_KEY=your-secret-key
|
||||
DEBUG=True
|
||||
DJANGO_SETTINGS_MODULE=config.django.local
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Email (optional)
|
||||
EMAIL_HOST=smtp.gmail.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USE_TLS=True
|
||||
EMAIL_HOST_USER=your-email@gmail.com
|
||||
EMAIL_HOST_PASSWORD=your-app-password
|
||||
```
|
||||
|
||||
### Settings Structure
|
||||
|
||||
- `config/django/base.py` - Base settings
|
||||
- `config/django/local.py` - Development settings
|
||||
- `config/django/production.py` - Production settings
|
||||
- `config/django/test.py` - Test settings
|
||||
|
||||
## 📁 Apps Overview
|
||||
|
||||
### Core Apps
|
||||
|
||||
- **accounts** - User authentication and profile management
|
||||
- **parks** - Theme park models and operations
|
||||
- **rides** - Ride information and relationships
|
||||
- **core** - Shared utilities and base classes
|
||||
|
||||
### Support Apps
|
||||
|
||||
- **moderation** - Content moderation workflows
|
||||
- **location** - Geographic data and services
|
||||
- **media** - File upload and management
|
||||
- **email_service** - Email sending and templates
|
||||
|
||||
## 🔌 API Endpoints
|
||||
|
||||
Base URL: `http://localhost:8000/api/`
|
||||
|
||||
### Authentication
|
||||
- `POST /auth/login/` - User login
|
||||
- `POST /auth/logout/` - User logout
|
||||
- `POST /auth/register/` - User registration
|
||||
|
||||
### Parks
|
||||
- `GET /parks/` - List parks
|
||||
- `GET /parks/{id}/` - Park details
|
||||
- `POST /parks/` - Create park (admin)
|
||||
|
||||
### Rides
|
||||
- `GET /rides/` - List rides
|
||||
- `GET /rides/{id}/` - Ride details
|
||||
- `GET /parks/{park_id}/rides/` - Rides by park
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
uv run manage.py test
|
||||
|
||||
# Run specific app tests
|
||||
uv run manage.py test apps.parks
|
||||
|
||||
# Run with coverage
|
||||
uv run coverage run manage.py test
|
||||
uv run coverage report
|
||||
```
|
||||
|
||||
## 🔧 Management Commands
|
||||
|
||||
Custom management commands:
|
||||
|
||||
```bash
|
||||
# Import park data
|
||||
uv run manage.py import_parks data/parks.json
|
||||
|
||||
# Generate test data
|
||||
uv run manage.py generate_test_data
|
||||
|
||||
# Clean up expired sessions
|
||||
uv run manage.py clearsessions
|
||||
```
|
||||
|
||||
## 📊 Database
|
||||
|
||||
### Entity Relationships
|
||||
|
||||
- **Parks** have Operators (required) and PropertyOwners (optional)
|
||||
- **Rides** belong to Parks and may have Manufacturers/Designers
|
||||
- **Users** can create submissions and moderate content
|
||||
|
||||
### Migrations
|
||||
|
||||
```bash
|
||||
# Create migrations
|
||||
uv run manage.py makemigrations
|
||||
|
||||
# Apply migrations
|
||||
uv run manage.py migrate
|
||||
|
||||
# Show migration status
|
||||
uv run manage.py showmigrations
|
||||
```
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
- CORS configured for frontend integration
|
||||
- CSRF protection enabled
|
||||
- JWT token authentication
|
||||
- Rate limiting on API endpoints
|
||||
- Input validation and sanitization
|
||||
|
||||
## 📈 Performance
|
||||
|
||||
- Database query optimization
|
||||
- Redis caching for frequent queries
|
||||
- Background task processing with Celery
|
||||
- Database connection pooling
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
See the [Deployment Guide](../shared/docs/deployment/) for production setup.
|
||||
|
||||
## 🐛 Debugging
|
||||
|
||||
### Development Tools
|
||||
|
||||
- Django Debug Toolbar
|
||||
- Django Extensions
|
||||
- Silk profiler for performance analysis
|
||||
|
||||
### Logging
|
||||
|
||||
Logs are written to:
|
||||
- Console (development)
|
||||
- Files in `logs/` directory (production)
|
||||
- External logging service (production)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Follow Django coding standards
|
||||
2. Write tests for new features
|
||||
3. Update documentation
|
||||
4. Run linting: `uv run flake8 .`
|
||||
5. Format code: `uv run black .`
|
||||
73
backend/VERIFICATION_COMMANDS.md
Normal file
73
backend/VERIFICATION_COMMANDS.md
Normal 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.
|
||||
64
backend/apps/accounts/adapters.py
Normal file
64
backend/apps/accounts/adapters.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from django.conf import settings
|
||||
from allauth.account.adapter import DefaultAccountAdapter
|
||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class CustomAccountAdapter(DefaultAccountAdapter):
|
||||
def is_open_for_signup(self, request):
|
||||
"""
|
||||
Whether to allow sign ups.
|
||||
"""
|
||||
return True
|
||||
|
||||
def get_email_confirmation_url(self, request, emailconfirmation):
|
||||
"""
|
||||
Constructs the email confirmation (activation) url.
|
||||
"""
|
||||
get_current_site(request)
|
||||
return f"{settings.LOGIN_REDIRECT_URL}verify-email?key={emailconfirmation.key}"
|
||||
|
||||
def send_confirmation_mail(self, request, emailconfirmation, signup):
|
||||
"""
|
||||
Sends the confirmation email.
|
||||
"""
|
||||
current_site = get_current_site(request)
|
||||
activate_url = self.get_email_confirmation_url(request, emailconfirmation)
|
||||
ctx = {
|
||||
"user": emailconfirmation.email_address.user,
|
||||
"activate_url": activate_url,
|
||||
"current_site": current_site,
|
||||
"key": emailconfirmation.key,
|
||||
}
|
||||
if signup:
|
||||
email_template = "account/email/email_confirmation_signup"
|
||||
else:
|
||||
email_template = "account/email/email_confirmation"
|
||||
self.send_mail(email_template, emailconfirmation.email_address.email, ctx)
|
||||
|
||||
|
||||
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
def is_open_for_signup(self, request, sociallogin):
|
||||
"""
|
||||
Whether to allow social account sign ups.
|
||||
"""
|
||||
return True
|
||||
|
||||
def populate_user(self, request, sociallogin, data):
|
||||
"""
|
||||
Hook that can be used to further populate the user instance.
|
||||
"""
|
||||
user = super().populate_user(request, sociallogin, data)
|
||||
if sociallogin.account.provider == "discord":
|
||||
user.discord_id = sociallogin.account.uid
|
||||
return user
|
||||
|
||||
def save_user(self, request, sociallogin, form=None):
|
||||
"""
|
||||
Save the newly signed up social login.
|
||||
"""
|
||||
user = super().save_user(request, sociallogin, form)
|
||||
return user
|
||||
@@ -1,10 +1,7 @@
|
||||
from typing import Any
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.utils.html import format_html
|
||||
from django.contrib.auth.models import Group
|
||||
from django.http import HttpRequest
|
||||
from django.db.models import QuerySet
|
||||
from .models import (
|
||||
User,
|
||||
UserProfile,
|
||||
@@ -15,7 +12,7 @@ from .models import (
|
||||
)
|
||||
|
||||
|
||||
class UserProfileInline(admin.StackedInline[UserProfile, admin.options.AdminSite]):
|
||||
class UserProfileInline(admin.StackedInline):
|
||||
model = UserProfile
|
||||
can_delete = False
|
||||
verbose_name_plural = "Profile"
|
||||
@@ -42,7 +39,7 @@ class UserProfileInline(admin.StackedInline[UserProfile, admin.options.AdminSite
|
||||
)
|
||||
|
||||
|
||||
class TopListItemInline(admin.TabularInline[TopListItem]):
|
||||
class TopListItemInline(admin.TabularInline):
|
||||
model = TopListItem
|
||||
extra = 1
|
||||
fields = ("content_type", "object_id", "rank", "notes")
|
||||
@@ -50,7 +47,7 @@ class TopListItemInline(admin.TabularInline[TopListItem]):
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class CustomUserAdmin(DjangoUserAdmin[User]):
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
list_display = (
|
||||
"username",
|
||||
"email",
|
||||
@@ -77,7 +74,7 @@ class CustomUserAdmin(DjangoUserAdmin[User]):
|
||||
"ban_users",
|
||||
"unban_users",
|
||||
]
|
||||
inlines: list[type[admin.StackedInline[UserProfile]]] = [UserProfileInline]
|
||||
inlines = [UserProfileInline]
|
||||
|
||||
fieldsets = (
|
||||
(None, {"fields": ("username", "password")}),
|
||||
@@ -129,82 +126,75 @@ class CustomUserAdmin(DjangoUserAdmin[User]):
|
||||
)
|
||||
|
||||
@admin.display(description="Avatar")
|
||||
def get_avatar(self, obj: User) -> str:
|
||||
profile = getattr(obj, "profile", None)
|
||||
if profile and getattr(profile, "avatar", None):
|
||||
def get_avatar(self, obj):
|
||||
if obj.profile.avatar:
|
||||
return format_html(
|
||||
'<img src="{0}" width="30" height="30" style="border-radius:50%;" />',
|
||||
getattr(profile.avatar, "url", ""), # type: ignore
|
||||
'<img src="{}" width="30" height="30" style="border-radius:50%;" />',
|
||||
obj.profile.avatar.url,
|
||||
)
|
||||
return format_html(
|
||||
'<div style="width:30px; height:30px; border-radius:50%; '
|
||||
"background-color:#007bff; color:white; display:flex; "
|
||||
'align-items:center; justify-content:center;">{0}</div>',
|
||||
getattr(obj, "username", "?")[0].upper(), # type: ignore
|
||||
'align-items:center; justify-content:center;">{}</div>',
|
||||
obj.username[0].upper(),
|
||||
)
|
||||
|
||||
@admin.display(description="Status")
|
||||
def get_status(self, obj: User) -> str:
|
||||
if getattr(obj, "is_banned", False):
|
||||
return format_html('<span style="color: red;">{}</span>', "Banned")
|
||||
if not getattr(obj, "is_active", True):
|
||||
return format_html('<span style="color: orange;">{}</span>', "Inactive")
|
||||
if getattr(obj, "is_superuser", False):
|
||||
return format_html('<span style="color: purple;">{}</span>', "Superuser")
|
||||
if getattr(obj, "is_staff", False):
|
||||
return format_html('<span style="color: blue;">{}</span>', "Staff")
|
||||
return format_html('<span style="color: green;">{}</span>', "Active")
|
||||
def get_status(self, obj):
|
||||
if obj.is_banned:
|
||||
return format_html('<span style="color: red;">Banned</span>')
|
||||
if not obj.is_active:
|
||||
return format_html('<span style="color: orange;">Inactive</span>')
|
||||
if obj.is_superuser:
|
||||
return format_html('<span style="color: purple;">Superuser</span>')
|
||||
if obj.is_staff:
|
||||
return format_html('<span style="color: blue;">Staff</span>')
|
||||
return format_html('<span style="color: green;">Active</span>')
|
||||
|
||||
@admin.display(description="Ride Credits")
|
||||
def get_credits(self, obj: User) -> str:
|
||||
def get_credits(self, obj):
|
||||
try:
|
||||
profile = getattr(obj, "profile", None)
|
||||
if not profile:
|
||||
return "-"
|
||||
profile = obj.profile
|
||||
return format_html(
|
||||
"RC: {0}<br>DR: {1}<br>FR: {2}<br>WR: {3}",
|
||||
getattr(profile, "coaster_credits", 0),
|
||||
getattr(profile, "dark_ride_credits", 0),
|
||||
getattr(profile, "flat_ride_credits", 0),
|
||||
getattr(profile, "water_ride_credits", 0),
|
||||
"RC: {}<br>DR: {}<br>FR: {}<br>WR: {}",
|
||||
profile.coaster_credits,
|
||||
profile.dark_ride_credits,
|
||||
profile.flat_ride_credits,
|
||||
profile.water_ride_credits,
|
||||
)
|
||||
except UserProfile.DoesNotExist:
|
||||
return "-"
|
||||
|
||||
@admin.action(description="Activate selected users")
|
||||
def activate_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
|
||||
def activate_users(self, request, queryset):
|
||||
queryset.update(is_active=True)
|
||||
|
||||
@admin.action(description="Deactivate selected users")
|
||||
def deactivate_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
|
||||
def deactivate_users(self, request, queryset):
|
||||
queryset.update(is_active=False)
|
||||
|
||||
@admin.action(description="Ban selected users")
|
||||
def ban_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
|
||||
def ban_users(self, request, queryset):
|
||||
from django.utils import timezone
|
||||
|
||||
queryset.update(is_banned=True, ban_date=timezone.now())
|
||||
|
||||
@admin.action(description="Unban selected users")
|
||||
def unban_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
|
||||
def unban_users(self, request, queryset):
|
||||
queryset.update(is_banned=False, ban_date=None, ban_reason="")
|
||||
|
||||
def save_model(
|
||||
self,
|
||||
request: HttpRequest,
|
||||
obj: User,
|
||||
form: Any,
|
||||
change: bool
|
||||
) -> None:
|
||||
def save_model(self, request, obj, form, change):
|
||||
creating = not obj.pk
|
||||
super().save_model(request, obj, form, change)
|
||||
if creating and getattr(obj, "role", "USER") != "USER":
|
||||
group = Group.objects.filter(name=getattr(obj, "role", None)).first()
|
||||
if creating and obj.role != User.Roles.USER:
|
||||
# Ensure new user with role gets added to appropriate group
|
||||
group = Group.objects.filter(name=obj.role).first()
|
||||
if group:
|
||||
obj.groups.add(group) # type: ignore[attr-defined]
|
||||
obj.groups.add(group)
|
||||
|
||||
|
||||
@admin.register(UserProfile)
|
||||
class UserProfileAdmin(admin.ModelAdmin[UserProfile]):
|
||||
class UserProfileAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"user",
|
||||
"display_name",
|
||||
@@ -245,7 +235,7 @@ class UserProfileAdmin(admin.ModelAdmin[UserProfile]):
|
||||
|
||||
|
||||
@admin.register(EmailVerification)
|
||||
class EmailVerificationAdmin(admin.ModelAdmin[EmailVerification]):
|
||||
class EmailVerificationAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "created_at", "last_sent", "is_expired")
|
||||
list_filter = ("created_at", "last_sent")
|
||||
search_fields = ("user__username", "user__email", "token")
|
||||
@@ -257,21 +247,21 @@ class EmailVerificationAdmin(admin.ModelAdmin[EmailVerification]):
|
||||
)
|
||||
|
||||
@admin.display(description="Status")
|
||||
def is_expired(self, obj: EmailVerification) -> str:
|
||||
def is_expired(self, obj):
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
if timezone.now() - getattr(obj, "last_sent", timezone.now()) > timedelta(days=1):
|
||||
return format_html('<span style="color: red;">{}</span>', "Expired")
|
||||
return format_html('<span style="color: green;">{}</span>', "Valid")
|
||||
if timezone.now() - obj.last_sent > timedelta(days=1):
|
||||
return format_html('<span style="color: red;">Expired</span>')
|
||||
return format_html('<span style="color: green;">Valid</span>')
|
||||
|
||||
|
||||
@admin.register(TopList)
|
||||
class TopListAdmin(admin.ModelAdmin[TopList]):
|
||||
class TopListAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "user", "category", "created_at", "updated_at")
|
||||
list_filter = ("category", "created_at", "updated_at")
|
||||
search_fields = ("title", "user__username", "description")
|
||||
inlines: list[type[admin.TabularInline[TopListItem]]] = [TopListItemInline]
|
||||
inlines = [TopListItemInline]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
@@ -287,7 +277,7 @@ class TopListAdmin(admin.ModelAdmin[TopList]):
|
||||
|
||||
|
||||
@admin.register(TopListItem)
|
||||
class TopListItemAdmin(admin.ModelAdmin[TopListItem]):
|
||||
class TopListItemAdmin(admin.ModelAdmin):
|
||||
list_display = ("top_list", "content_type", "object_id", "rank")
|
||||
list_filter = ("top_list__category", "rank")
|
||||
search_fields = ("top_list__title", "notes")
|
||||
@@ -300,7 +290,7 @@ class TopListItemAdmin(admin.ModelAdmin[TopListItem]):
|
||||
|
||||
|
||||
@admin.register(PasswordReset)
|
||||
class PasswordResetAdmin(admin.ModelAdmin[PasswordReset]):
|
||||
class PasswordResetAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for password reset tokens"""
|
||||
|
||||
list_display = (
|
||||
@@ -351,19 +341,20 @@ class PasswordResetAdmin(admin.ModelAdmin[PasswordReset]):
|
||||
)
|
||||
|
||||
@admin.display(description="Status", boolean=True)
|
||||
def is_expired(self, obj: PasswordReset) -> str:
|
||||
def is_expired(self, obj):
|
||||
"""Display expiration status with color coding"""
|
||||
from django.utils import timezone
|
||||
|
||||
if getattr(obj, "used", False):
|
||||
return format_html('<span style="color: blue;">{}</span>', "Used")
|
||||
elif timezone.now() > getattr(obj, "expires_at", timezone.now()):
|
||||
return format_html('<span style="color: red;">{}</span>', "Expired")
|
||||
return format_html('<span style="color: green;">{}</span>', "Valid")
|
||||
if obj.used:
|
||||
return format_html('<span style="color: blue;">Used</span>')
|
||||
elif timezone.now() > obj.expires_at:
|
||||
return format_html('<span style="color: red;">Expired</span>')
|
||||
return format_html('<span style="color: green;">Valid</span>')
|
||||
|
||||
def has_add_permission(self, request: HttpRequest) -> bool:
|
||||
def has_add_permission(self, request):
|
||||
"""Disable manual creation of password reset tokens"""
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request: HttpRequest, obj: Any = None) -> bool:
|
||||
def has_change_permission(self, request, obj=None):
|
||||
"""Allow viewing but restrict editing of password reset tokens"""
|
||||
return getattr(request.user, "is_superuser", False)
|
||||
@@ -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()
|
||||
|
||||
551
backend/apps/accounts/migrations/0001_initial.py
Normal file
551
backend/apps/accounts/migrations/0001_initial.py
Normal 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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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", "0007_auto_20250421_0444"),
|
||||
]
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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", "0007_auto_20250421_0444"),
|
||||
]
|
||||
|
||||
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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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", "0007_auto_20250421_0444"),
|
||||
]
|
||||
|
||||
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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
106
backend/apps/accounts/migrations/0010_auto_20250830_1657.py
Normal file
106
backend/apps/accounts/migrations/0010_auto_20250830_1657.py
Normal 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,
|
||||
),
|
||||
]
|
||||
@@ -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;"
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -121,6 +121,10 @@ 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
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@@ -631,6 +635,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)
|
||||
@@ -31,7 +31,7 @@ class UserDeletionService:
|
||||
"is_active": False,
|
||||
"is_staff": False,
|
||||
"is_superuser": False,
|
||||
"role": "USER",
|
||||
"role": User.Roles.USER,
|
||||
"is_banned": True,
|
||||
"ban_reason": "System placeholder for deleted users",
|
||||
"ban_date": timezone.now(),
|
||||
@@ -178,7 +178,7 @@ class UserDeletionService:
|
||||
return False, "Superuser accounts cannot be deleted for security reasons. Please contact system administrator or remove superuser privileges first."
|
||||
|
||||
# Check if user has critical admin role
|
||||
if user.role == "ADMIN" and user.is_staff:
|
||||
if user.role == User.Roles.ADMIN and user.is_staff:
|
||||
return False, "Admin accounts with staff privileges cannot be deleted. Please remove admin privileges first or contact system administrator."
|
||||
|
||||
# Add any other business rules here
|
||||
@@ -10,41 +10,59 @@ from .models import User, UserProfile
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_user_profile(sender, instance, created, **kwargs):
|
||||
"""Create UserProfile for new users - unified signal handler"""
|
||||
if created:
|
||||
"""Create UserProfile for new users"""
|
||||
try:
|
||||
if created:
|
||||
# Create profile
|
||||
profile = UserProfile.objects.create(user=instance)
|
||||
|
||||
# If user has a social account with avatar, download it
|
||||
social_account = instance.socialaccount_set.first()
|
||||
if social_account:
|
||||
extra_data = social_account.extra_data
|
||||
avatar_url = None
|
||||
|
||||
if social_account.provider == "google":
|
||||
avatar_url = extra_data.get("picture")
|
||||
elif social_account.provider == "discord":
|
||||
avatar = extra_data.get("avatar")
|
||||
discord_id = extra_data.get("id")
|
||||
if avatar:
|
||||
avatar_url = f"https://cdn.discordapp.com/avatars/{discord_id}/{avatar}.png"
|
||||
|
||||
if avatar_url:
|
||||
try:
|
||||
response = requests.get(avatar_url, timeout=60)
|
||||
if response.status_code == 200:
|
||||
img_temp = NamedTemporaryFile(delete=True)
|
||||
img_temp.write(response.content)
|
||||
img_temp.flush()
|
||||
|
||||
file_name = f"avatar_{instance.username}.png"
|
||||
profile.avatar.save(file_name, File(img_temp), save=True)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Error downloading avatar for user {instance.username}: {
|
||||
str(e)
|
||||
}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error creating profile for user {instance.username}: {str(e)}")
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def save_user_profile(sender, instance, **kwargs):
|
||||
"""Ensure UserProfile exists and is saved"""
|
||||
try:
|
||||
# Try to get existing profile first
|
||||
try:
|
||||
# Use get_or_create to prevent duplicates
|
||||
profile, profile_created = UserProfile.objects.get_or_create(user=instance)
|
||||
|
||||
if profile_created:
|
||||
# If user has a social account with avatar, download it
|
||||
try:
|
||||
social_account = instance.socialaccount_set.first()
|
||||
if social_account:
|
||||
extra_data = social_account.extra_data
|
||||
avatar_url = None
|
||||
|
||||
if social_account.provider == "google":
|
||||
avatar_url = extra_data.get("picture")
|
||||
elif social_account.provider == "discord":
|
||||
avatar = extra_data.get("avatar")
|
||||
discord_id = extra_data.get("id")
|
||||
if avatar:
|
||||
avatar_url = f"https://cdn.discordapp.com/avatars/{discord_id}/{avatar}.png"
|
||||
|
||||
if avatar_url:
|
||||
response = requests.get(avatar_url, timeout=60)
|
||||
if response.status_code == 200:
|
||||
img_temp = NamedTemporaryFile(delete=True)
|
||||
img_temp.write(response.content)
|
||||
img_temp.flush()
|
||||
|
||||
file_name = f"avatar_{instance.username}.png"
|
||||
profile.avatar.save(file_name, File(img_temp), save=True)
|
||||
except Exception as e:
|
||||
print(f"Error downloading avatar for user {instance.username}: {str(e)}")
|
||||
except Exception as e:
|
||||
print(f"Error creating profile for user {instance.username}: {str(e)}")
|
||||
profile = instance.profile
|
||||
profile.save()
|
||||
except UserProfile.DoesNotExist:
|
||||
# Profile doesn't exist, create it
|
||||
UserProfile.objects.create(user=instance)
|
||||
except Exception as e:
|
||||
print(f"Error saving profile for user {instance.username}: {str(e)}")
|
||||
|
||||
|
||||
@receiver(pre_save, sender=User)
|
||||
@@ -57,43 +75,43 @@ def sync_user_role_with_groups(sender, instance, **kwargs):
|
||||
# Role has changed, update groups
|
||||
with transaction.atomic():
|
||||
# Remove from old role group if exists
|
||||
if old_instance.role != "USER":
|
||||
if old_instance.role != User.Roles.USER:
|
||||
old_group = Group.objects.filter(name=old_instance.role).first()
|
||||
if old_group:
|
||||
instance.groups.remove(old_group)
|
||||
|
||||
# Add to new role group
|
||||
if instance.role != "USER":
|
||||
if instance.role != User.Roles.USER:
|
||||
new_group, _ = Group.objects.get_or_create(name=instance.role)
|
||||
instance.groups.add(new_group)
|
||||
|
||||
# Special handling for superuser role
|
||||
if instance.role == "SUPERUSER":
|
||||
if instance.role == User.Roles.SUPERUSER:
|
||||
instance.is_superuser = True
|
||||
instance.is_staff = True
|
||||
elif old_instance.role == "SUPERUSER":
|
||||
elif old_instance.role == User.Roles.SUPERUSER:
|
||||
# If removing superuser role, remove superuser
|
||||
# status
|
||||
instance.is_superuser = False
|
||||
if instance.role not in [
|
||||
"ADMIN",
|
||||
"MODERATOR",
|
||||
User.Roles.ADMIN,
|
||||
User.Roles.MODERATOR,
|
||||
]:
|
||||
instance.is_staff = False
|
||||
|
||||
# Handle staff status for admin and moderator roles
|
||||
if instance.role in [
|
||||
"ADMIN",
|
||||
"MODERATOR",
|
||||
User.Roles.ADMIN,
|
||||
User.Roles.MODERATOR,
|
||||
]:
|
||||
instance.is_staff = True
|
||||
elif old_instance.role in [
|
||||
"ADMIN",
|
||||
"MODERATOR",
|
||||
User.Roles.ADMIN,
|
||||
User.Roles.MODERATOR,
|
||||
]:
|
||||
# If removing admin/moderator role, remove staff
|
||||
# status
|
||||
if instance.role not in ["SUPERUSER"]:
|
||||
if instance.role not in [User.Roles.SUPERUSER]:
|
||||
instance.is_staff = False
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
@@ -112,7 +130,7 @@ def create_default_groups():
|
||||
from django.contrib.auth.models import Permission
|
||||
|
||||
# Create Moderator group
|
||||
moderator_group, _ = Group.objects.get_or_create(name="MODERATOR")
|
||||
moderator_group, _ = Group.objects.get_or_create(name=User.Roles.MODERATOR)
|
||||
moderator_permissions = [
|
||||
# Review moderation permissions
|
||||
"change_review",
|
||||
@@ -131,7 +149,7 @@ def create_default_groups():
|
||||
]
|
||||
|
||||
# Create Admin group
|
||||
admin_group, _ = Group.objects.get_or_create(name="ADMIN")
|
||||
admin_group, _ = Group.objects.get_or_create(name=User.Roles.ADMIN)
|
||||
admin_permissions = moderator_permissions + [
|
||||
# User management permissions
|
||||
"change_user",
|
||||
@@ -109,7 +109,7 @@ class SignalsTestCase(TestCase):
|
||||
|
||||
create_default_groups()
|
||||
|
||||
moderator_group = Group.objects.get(name="MODERATOR")
|
||||
moderator_group = Group.objects.get(name=User.Roles.MODERATOR)
|
||||
self.assertIsNotNone(moderator_group)
|
||||
self.assertTrue(
|
||||
moderator_group.permissions.filter(codename="change_review").exists()
|
||||
@@ -118,7 +118,7 @@ class SignalsTestCase(TestCase):
|
||||
moderator_group.permissions.filter(codename="change_user").exists()
|
||||
)
|
||||
|
||||
admin_group = Group.objects.get(name="ADMIN")
|
||||
admin_group = Group.objects.get(name=User.Roles.ADMIN)
|
||||
self.assertIsNotNone(admin_group)
|
||||
self.assertTrue(
|
||||
admin_group.permissions.filter(codename="change_review").exists()
|
||||
@@ -42,7 +42,7 @@ class UserDeletionServiceTest(TestCase):
|
||||
self.assertEqual(deleted_user.email, "deleted@thrillwiki.com")
|
||||
self.assertFalse(deleted_user.is_active)
|
||||
self.assertTrue(deleted_user.is_banned)
|
||||
self.assertEqual(deleted_user.role, "USER")
|
||||
self.assertEqual(deleted_user.role, User.Roles.USER)
|
||||
|
||||
# Check profile was created
|
||||
self.assertTrue(hasattr(deleted_user, "profile"))
|
||||
6
backend/apps/api/__init__.py
Normal file
6
backend/apps/api/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Centralized API package for ThrillWiki
|
||||
|
||||
All API endpoints MUST be defined here under the /api/v1/ structure.
|
||||
This enforces consistent API architecture and prevents rogue endpoint creation.
|
||||
"""
|
||||
23
backend/apps/api/apps.py
Normal file
23
backend/apps/api/apps.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
ThrillWiki API App Configuration
|
||||
|
||||
This module contains the Django app configuration for the centralized API application.
|
||||
All API endpoints are routed through this app following the pattern:
|
||||
- Frontend: /api/{endpoint}
|
||||
- Vite Proxy: /api/ -> /api/v1/
|
||||
- Django: backend/api/v1/{endpoint}
|
||||
"""
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
"""Configuration for the centralized API app."""
|
||||
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "api"
|
||||
verbose_name = "ThrillWiki API"
|
||||
|
||||
def ready(self):
|
||||
"""Import signals when the app is ready."""
|
||||
import apps.api.v1.signals # noqa: F401
|
||||
1
backend/apps/api/management/__init__.py
Normal file
1
backend/apps/api/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Management commands package
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user