Compare commits

..

2 Commits

Author SHA1 Message Date
pacnpal
fdbbca2add Refactor code structure for improved readability and maintainability 2025-09-27 19:35:00 -04:00
pacnpal
bf365693f8 fix: Update .gitignore to include .snapshots directory 2025-09-27 12:57:37 -04:00
677 changed files with 46930 additions and 20021 deletions

View File

@@ -1,3 +1,51 @@
## Brief overview
Critical thinking rules for frontend design decisions. No excuses for poor design choices that ignore user vision.
## 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
## 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 NESTING**: All API directory structures MUST match URL nesting patterns. No exceptions.
- **NO TOP-LEVEL ENDPOINTS**: URLs must be nested under top-level domains
@@ -37,6 +85,7 @@
- **Statistics**: Cached stats endpoints with automatic invalidation via Django signals
## CRITICAL RULES
- **DOCUMENTATION**: After every change, it is MANDATORY to update docs/frontend.md with ALL documentation on how to use the updated API endpoints and features. It is MANDATORY to include any types in docs/types-api.ts for NextJS as the file would appear in `src/types/api.ts`. It is MANDATORY to include any new API endpoints in docs/lib-api.ts for NextJS as the file would appear in `/src/lib/api.ts`. Maintain accuracy and compliance in all technical documentation. Ensure API documentation matches backend URL routing expectations.
- **NEVER MOCK DATA**: You are NEVER EVER to mock any data unless it's ONLY for API schema documentation purposes. All data must come from real database queries and actual model instances. Mock data is STRICTLY FORBIDDEN in all API responses, services, and business logic.
- **DOMAIN SEPARATION**: Company roles OPERATOR and PROPERTY_OWNER are EXCLUSIVELY for parks domain. They should NEVER be used in rides URLs or ride-related contexts. Only MANUFACTURER and DESIGNER roles are for rides domain. Parks: `/parks/{park_slug}/` and `/parks/`. Rides: `/parks/{park_slug}/rides/{ride_slug}/` and `/rides/`. Parks Companies: `/parks/operators/{operator_slug}/` and `/parks/owners/{owner_slug}/`. Rides Companies: `/rides/manufacturers/{manufacturer_slug}/` and `/rides/designers/{designer_slug}/`. NEVER mix these domains - this is a fundamental and DANGEROUS business rule violation.
- **PHOTO MANAGEMENT**: Use CloudflareImagesField for all photo uploads with variants and transformations. Clearly define and use photo types (e.g., banner, card) for all images. Include attribution fields for all photos. Implement logic to determine the primary photo for each model.

View File

@@ -1,50 +0,0 @@
## ThrillWiki Django App Context
### 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
- **Backend**: Django 5.0+ with DRF, PostgreSQL + PostGIS, Redis caching, Celery tasks
- **Frontend**: HTMX + AlpineJS + Tailwind CSS + Django-Cotton (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 Structure
- **Parks Domain**: parks/, companies (OPERATOR/PROPERTY_OWNER roles only)
- **Rides Domain**: rides/, companies (MANUFACTURER/DESIGNER roles only)
- **Core Apps**: accounts/, media/, moderation/, core/
- **CRITICAL**: Never mix park/ride company roles - fundamental business rule
### Key Patterns
- **Models**: All inherit TrackedModel, use SluggedModel for slugs with history
- **API**: Nested URLs (/parks/{slug}/rides/{slug}/), mandatory trailing slashes
- **Choices**: RichChoiceField with metadata (color, icon, description, css_class)
- **Photos**: CloudflareImagesField with photo types and attribution
- **Location**: PostGIS for geographic data, separate location models
### Development Commands
- **Server**: `uv run manage.py runserver_plus` (NOT python manage.py)
- **Migrations**: `uv run manage.py makemigrations/migrate`
- **Packages**: `uv add <package>` (NOT pip install)
- **Management**: Always use `uv run manage.py <command>`
### API Rules
- **Authentication**: Token-based with role hierarchy (USER/MODERATOR/ADMIN/SUPERUSER)
- **Filtering**: Comprehensive filtering on 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
- **NEVER MOCK DATA**: All responses must use real database queries
### Rich Choice Objects (MANDATORY)
- Use `RichChoiceField(choice_group="group_name", domain="domain_name")`
- Define choices in domain-specific `choices.py` using RichChoice dataclass
- Register with `register_choices()` function in domain `__init__.py`
- Include rich metadata: color, icon, description, css_class minimum
- NO tuple-based choices allowed anywhere in codebase
### Frontend Constraints
- HTMX for dynamic updates, AlpineJS for client state, Tailwind for styling
- Progressive enhancement approach required
- Must support latest 2 versions of major browsers
- Performance targets: FCP < 1.5s, TTI < 2s, Core Web Vitals compliance

View File

@@ -1,212 +0,0 @@
<thrillwiki_context>
<!-- Core Project Information -->
<project_overview>
<name>ThrillWiki</name>
<description>Comprehensive theme park database platform with user-generated content, expert moderation, and rich media support</description>
<framework>Django REST Framework</framework>
<api_endpoints>120+</api_endpoints>
<primary_entities>
<entity>Parks</entity>
<entity>Rides</entity>
<entity>Companies</entity>
<entity>Users</entity>
</primary_entities>
</project_overview>
<!-- Technology Stack -->
<tech_stack>
<backend>
<framework>Django 5.0+</framework>
<api>Django REST Framework</api>
<database>PostgreSQL with PostGIS</database>
<caching>Redis</caching>
<task_queue>Celery</task_queue>
</backend>
<frontend>
<constraint>NO React/Vue/Angular allowed</constraint>
<technologies>
<tech name="HTMX" purpose="Dynamic updates"/>
<tech name="AlpineJS" purpose="Client-side state management"/>
<tech name="Tailwind CSS" purpose="Styling"/>
<tech name="Django-Cotton" purpose="Component system"/>
</technologies>
</frontend>
<media>
<service>Cloudflare Images</service>
<method>Direct Upload with variants and transformations</method>
</media>
<tracking>
<system>pghistory for all model changes</system>
<base_class>TrackedModel</base_class>
</tracking>
</tech_stack>
<!-- Domain Architecture -->
<domain_structure>
<domain name="parks">
<path>parks/</path>
<companies>
<allowed_roles>
<role>OPERATOR</role>
<role>PROPERTY_OWNER</role>
</allowed_roles>
</companies>
</domain>
<domain name="rides">
<path>rides/</path>
<companies>
<allowed_roles>
<role>MANUFACTURER</role>
<role>DESIGNER</role>
</allowed_roles>
</companies>
</domain>
<core_apps>
<app>accounts/</app>
<app>media/</app>
<app>moderation/</app>
<app>core/</app>
</core_apps>
<critical_rules>
<rule>NEVER mix park/ride company roles - fundamental business rule</rule>
</critical_rules>
</domain_structure>
<!-- Development Patterns -->
<development_patterns>
<models>
<base_class>TrackedModel (all models inherit)</base_class>
<slug_handling>SluggedModel for slugs with history</slug_handling>
</models>
<api_design>
<url_structure>Nested URLs (/parks/{slug}/rides/{slug}/)</url_structure>
<trailing_slash>Mandatory trailing slashes</trailing_slash>
</api_design>
<choices>
<system>RichChoiceField with metadata</system>
<metadata>color, icon, description, css_class</metadata>
<forbidden>NEVER use Django tuple choices</forbidden>
</choices>
<media_handling>
<field_type>CloudflareImagesField</field_type>
<features>photo types and attribution</features>
</media_handling>
<location>
<system>PostGIS for geographic data</system>
<structure>separate location models</structure>
</location>
</development_patterns>
<!-- Development Commands -->
<commands>
<server>
<command>uv run manage.py runserver_plus</command>
<forbidden>python manage.py</forbidden>
</server>
<migrations>
<make>uv run manage.py makemigrations</make>
<apply>uv run manage.py migrate</apply>
</migrations>
<packages>
<install>uv add &lt;package&gt;</install>
<forbidden>pip install</forbidden>
</packages>
<management>
<pattern>Always use `uv run manage.py &lt;command&gt;`</pattern>
</management>
</commands>
<!-- API Rules and Standards -->
<api_standards>
<authentication>
<type>Token-based</type>
<role_hierarchy>
<role level="1">USER</role>
<role level="2">MODERATOR</role>
<role level="3">ADMIN</role>
<role level="4">SUPERUSER</role>
</role_hierarchy>
</authentication>
<filtering>
<rides>25+ parameters</rides>
<parks>15+ parameters</parks>
</filtering>
<responses>
<pagination>Standard DRF pagination</pagination>
<errors>Rich error responses with details</errors>
</responses>
<caching>
<levels>Multi-level (Redis, CDN, browser)</levels>
<invalidation>Signal-based invalidation</invalidation>
</caching>
<data_policy>
<rule>NEVER MOCK DATA - All responses must use real database queries</rule>
</data_policy>
</api_standards>
<!-- Rich Choice Objects System (MANDATORY) -->
<rich_choices>
<implementation>
<field_usage>RichChoiceField(choice_group="group_name", domain="domain_name")</field_usage>
<definition_location>domain-specific choices.py using RichChoice dataclass</definition_location>
<registration>register_choices() function in domain __init__.py</registration>
</implementation>
<required_metadata>
<metadata>color</metadata>
<metadata>icon</metadata>
<metadata>description</metadata>
<metadata>css_class</metadata>
</required_metadata>
<forbidden>
<rule>NO tuple-based choices allowed anywhere in codebase</rule>
</forbidden>
</rich_choices>
<!-- Frontend Constraints -->
<frontend_constraints>
<architecture>
<htmx>Dynamic updates</htmx>
<alpinejs>Client state management</alpinejs>
<tailwind>Styling framework</tailwind>
</architecture>
<approach>Progressive enhancement required</approach>
<browser_support>Latest 2 versions of major browsers</browser_support>
<performance_targets>
<fcp>First Contentful Paint &lt; 1.5s</fcp>
<tti>Time to Interactive &lt; 2s</tti>
<compliance>Core Web Vitals compliance</compliance>
</performance_targets>
</frontend_constraints>
<!-- Code Quality Standards -->
<code_standards>
<model_inheritance>All models must inherit from TrackedModel</model_inheritance>
<slug_management>Use SluggedModel for entities with slugs and history tracking</slug_management>
<choice_fields>Always use RichChoiceField instead of Django choices</choice_fields>
<api_urls>Must include trailing slashes and follow nested pattern</api_urls>
<media_fields>Use CloudflareImagesField for all image handling</media_fields>
<geographic_data>Use PostGIS fields and separate location models</geographic_data>
</code_standards>
<!-- Business Rules -->
<business_rules>
<company_roles>
<rule>Parks domain: Only OPERATOR and PROPERTY_OWNER roles</rule>
<rule>Rides domain: Only MANUFACTURER and DESIGNER roles</rule>
<rule>CRITICAL: Never allow cross-domain company roles</rule>
</company_roles>
<data_integrity>
<rule>All model changes must be tracked via pghistory</rule>
<rule>All API responses must use real database data</rule>
<rule>Geographic data must use PostGIS for accuracy</rule>
</data_integrity>
</business_rules>
<!-- Development Workflow -->
<workflow>
<package_management>Use uv for all Python package operations</package_management>
<server_management>Use runserver_plus for enhanced development server</server_management>
<database_management>Always use uv run for Django management commands</database_management>
<testing>All functionality must work with progressive enhancement</testing>
</workflow>
</thrillwiki_context>

73
.replit
View File

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

View File

@@ -1,753 +0,0 @@
# Park Listing Performance Optimization Documentation
## Overview
This document provides comprehensive documentation for the performance optimizations implemented for the ThrillWiki park listing page. The optimizations focus on query performance, database indexing, pagination efficiency, strategic caching, frontend performance, and load testing capabilities.
## Table of Contents
1. [Query Optimization Analysis](#query-optimization-analysis)
2. [Database Indexing Strategy](#database-indexing-strategy)
3. [Pagination Efficiency](#pagination-efficiency)
4. [Caching Strategy](#caching-strategy)
5. [Frontend Performance](#frontend-performance)
6. [Load Testing & Benchmarking](#load-testing--benchmarking)
7. [Deployment Recommendations](#deployment-recommendations)
8. [Performance Monitoring](#performance-monitoring)
9. [Maintenance Guidelines](#maintenance-guidelines)
## Query Optimization Analysis
### Issues Identified and Resolved
#### 1. Critical Anti-Pattern Elimination
**Problem**: The original `ParkListView.get_queryset()` used an expensive subquery pattern:
```python
# BEFORE - Expensive subquery anti-pattern
final_queryset = queryset.filter(
pk__in=filtered_queryset.values_list('pk', flat=True)
)
```
**Solution**: Implemented direct filtering with optimized queryset building:
```python
# AFTER - Optimized direct filtering
queryset = self.filter_service.get_optimized_filtered_queryset(filter_params)
```
#### 2. Optimized Select Related and Prefetch Related
**Improvements**:
- Consolidated duplicate select_related calls
- Added strategic prefetch_related for related models
- Implemented proper annotations for calculated fields
```python
queryset = (
Park.objects
.select_related("operator", "property_owner", "location", "banner_image", "card_image")
.prefetch_related("photos", "rides__manufacturer", "areas")
.annotate(
current_ride_count=Count("rides", distinct=True),
current_coaster_count=Count("rides", filter=Q(rides__category="RC"), distinct=True),
)
)
```
#### 3. Filter Service Aggregation Optimization
**Problem**: Multiple separate COUNT queries causing N+1 issues
```python
# BEFORE - Multiple COUNT queries
filter_counts = {
"total_parks": base_queryset.count(),
"operating_parks": base_queryset.filter(status="OPERATING").count(),
"parks_with_coasters": base_queryset.filter(coaster_count__gt=0).count(),
# ... more individual count queries
}
```
**Solution**: Single aggregated query with conditional counting:
```python
# AFTER - Single optimized aggregate query
aggregates = base_queryset.aggregate(
total_parks=Count('id'),
operating_parks=Count('id', filter=Q(status='OPERATING')),
parks_with_coasters=Count('id', filter=Q(coaster_count__gt=0)),
# ... all counts in one query
)
```
#### 4. Autocomplete Query Optimization
**Improvements**:
- Eliminated separate queries for parks, operators, and locations
- Implemented single optimized query using `search_text` field
- Added proper caching with session storage
### Performance Impact
- **Query count reduction**: 70-85% reduction in database queries
- **Response time improvement**: 60-80% faster page loads
- **Memory usage optimization**: 40-50% reduction in memory consumption
## Database Indexing Strategy
### Implemented Indexes
#### 1. Composite Indexes for Common Filter Combinations
```sql
-- Status and operator filtering (most common combination)
CREATE INDEX CONCURRENTLY idx_parks_status_operator ON parks_park(status, operator_id);
-- Park type and status filtering
CREATE INDEX CONCURRENTLY idx_parks_park_type_status ON parks_park(park_type, status);
-- Opening year filtering with status
CREATE INDEX CONCURRENTLY idx_parks_opening_year_status ON parks_park(opening_year, status)
WHERE opening_year IS NOT NULL;
```
#### 2. Performance Indexes for Statistics
```sql
-- Ride count and coaster count filtering
CREATE INDEX CONCURRENTLY idx_parks_ride_count_coaster_count ON parks_park(ride_count, coaster_count)
WHERE ride_count IS NOT NULL;
-- Rating-based filtering
CREATE INDEX CONCURRENTLY idx_parks_average_rating_status ON parks_park(average_rating, status)
WHERE average_rating IS NOT NULL;
```
#### 3. Text Search Optimization
```sql
-- GIN index for full-text search using trigrams
CREATE INDEX CONCURRENTLY idx_parks_search_text_gin ON parks_park
USING gin(search_text gin_trgm_ops);
-- Company name search for operator filtering
CREATE INDEX CONCURRENTLY idx_company_name_roles ON parks_company
USING gin(name gin_trgm_ops, roles);
```
#### 4. Location-Based Indexes
```sql
-- Country and city combination filtering
CREATE INDEX CONCURRENTLY idx_parklocation_country_city ON parks_parklocation(country, city);
-- Spatial coordinates for map queries
CREATE INDEX CONCURRENTLY idx_parklocation_coordinates ON parks_parklocation(latitude, longitude)
WHERE latitude IS NOT NULL AND longitude IS NOT NULL;
```
### Migration Application
```bash
# Apply the performance indexes
python manage.py migrate parks 0002_add_performance_indexes
# Monitor index creation progress
python manage.py dbshell -c "
SELECT
schemaname, tablename, attname, n_distinct, correlation
FROM pg_stats
WHERE tablename IN ('parks_park', 'parks_parklocation', 'parks_company')
ORDER BY schemaname, tablename, attname;
"
```
### Index Maintenance
- **Monitoring**: Regular analysis of query performance
- **Updates**: Quarterly review of index usage statistics
- **Cleanup**: Annual removal of unused indexes
## Pagination Efficiency
### Optimized Paginator Implementation
#### 1. COUNT Query Optimization
```python
class OptimizedPaginator(Paginator):
def _get_optimized_count(self) -> int:
"""Use subquery approach for complex queries"""
if self._is_complex_query(queryset):
subquery = queryset.values('pk')
return subquery.count()
return queryset.count()
```
#### 2. Cursor-Based Pagination for Large Datasets
```python
class CursorPaginator:
"""More efficient than offset-based pagination for large page numbers"""
def get_page(self, cursor: Optional[str] = None) -> Dict[str, Any]:
if cursor:
cursor_value = self._decode_cursor(cursor)
queryset = queryset.filter(**{f"{self.field_name}__gt": cursor_value})
items = list(queryset[:self.per_page + 1])
has_next = len(items) > self.per_page
# ... pagination logic
```
#### 3. Pagination Caching
```python
class PaginationCache:
"""Cache pagination metadata and results"""
@classmethod
def cache_page_results(cls, queryset_hash: str, page_num: int, page_data: Dict[str, Any]):
cache_key = cls.get_page_cache_key(queryset_hash, page_num)
cache.set(cache_key, page_data, cls.DEFAULT_TIMEOUT)
```
### Performance Benefits
- **Large datasets**: 90%+ improvement for pages beyond page 100
- **Complex filters**: 70% improvement with multiple filter combinations
- **Memory usage**: 60% reduction in memory consumption
## Caching Strategy
### Comprehensive Caching Service
#### 1. Strategic Cache Categories
```python
class CacheService:
# Cache prefixes for different data types
FILTER_COUNTS = "park_filter_counts" # 15 minutes
AUTOCOMPLETE = "park_autocomplete" # 5 minutes
SEARCH_RESULTS = "park_search" # 10 minutes
CLOUDFLARE_IMAGES = "cf_images" # 1 hour
PARK_STATS = "park_stats" # 30 minutes
PAGINATED_RESULTS = "park_paginated" # 5 minutes
```
#### 2. Intelligent Cache Invalidation
```python
@classmethod
def invalidate_related_caches(cls, model_name: str, instance_id: Optional[int] = None):
invalidation_map = {
'park': [cls.FILTER_COUNTS, cls.SEARCH_RESULTS, cls.PARK_STATS, cls.AUTOCOMPLETE],
'company': [cls.FILTER_COUNTS, cls.AUTOCOMPLETE],
'parklocation': [cls.SEARCH_RESULTS, cls.FILTER_COUNTS],
'parkphoto': [cls.CLOUDFLARE_IMAGES],
}
```
#### 3. CloudFlare Image Caching
```python
class CloudFlareImageCache:
@classmethod
def get_optimized_image_url(cls, image_id: str, variant: str = "public", width: Optional[int] = None):
cached_url = CacheService.get_cached_cloudflare_image(image_id, f"{variant}_{width}")
if cached_url:
return cached_url
# Generate and cache optimized URL
url = f"{base_url}/{image_id}/w={width}" if width else f"{base_url}/{image_id}/{variant}"
CacheService.cache_cloudflare_image(image_id, f"{variant}_{width}", url)
return url
```
### Cache Performance Metrics
- **Hit rate**: 85-95% for frequently accessed data
- **Response time**: 80-90% improvement for cached requests
- **Database load**: 70% reduction in database queries
## Frontend Performance
### JavaScript Optimizations
#### 1. Lazy Loading with Intersection Observer
```javascript
setupLazyLoading() {
this.imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadImage(entry.target);
this.imageObserver.unobserve(entry.target);
}
});
}, this.observerOptions);
}
```
#### 2. Debounced Search with Caching
```javascript
setupDebouncedSearch() {
searchInput.addEventListener('input', (e) => {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.performSearch(query);
}, 300);
});
}
async performSearch(query) {
// Check session storage cache first
const cached = sessionStorage.getItem(`search_${query.toLowerCase()}`);
if (cached) {
this.displaySuggestions(JSON.parse(cached));
return;
}
// ... fetch and cache results
}
```
#### 3. Progressive Image Loading
```javascript
setupProgressiveImageLoading() {
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);
}
});
}
```
### CSS Optimizations
#### 1. GPU Acceleration
```css
.park-listing {
transform: translateZ(0);
backface-visibility: hidden;
}
.park-card {
will-change: transform, box-shadow;
transition: transform 0.2s ease, box-shadow 0.2s ease;
transform: translateZ(0);
contain: layout style paint;
}
```
#### 2. Efficient Grid Layout
```css
.park-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
contain: layout style;
}
```
#### 3. Loading States
```css
img[data-src] {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
```
### Performance Metrics
- **First Contentful Paint**: 40-60% improvement
- **Largest Contentful Paint**: 50-70% improvement
- **Cumulative Layout Shift**: 80% reduction
- **JavaScript bundle size**: 30% reduction
## Load Testing & Benchmarking
### Benchmarking Suite
#### 1. Autocomplete Performance Testing
```python
def run_autocomplete_benchmark(self, queries: List[str] = None):
queries = ['Di', 'Disney', 'Universal', 'Cedar Point', 'California', 'Roller', 'Xyz123']
for query in queries:
with self.monitor.measure_operation(f"autocomplete_{query}"):
# Test autocomplete performance
response = view.get(request)
```
#### 2. Listing Performance Testing
```python
def run_listing_benchmark(self, scenarios: List[Dict[str, Any]] = None):
scenarios = [
{'name': 'no_filters', 'params': {}},
{'name': 'status_filter', 'params': {'status': 'OPERATING'}},
{'name': 'complex_filter', 'params': {
'status': 'OPERATING', 'has_coasters': 'true', 'min_rating': '4.0'
}},
# ... more scenarios
]
```
#### 3. Pagination Performance Testing
```python
def run_pagination_benchmark(self, page_sizes=[10, 20, 50, 100], page_numbers=[1, 5, 10, 50]):
for page_size in page_sizes:
for page_number in page_numbers:
with self.monitor.measure_operation(f"page_{page_number}_size_{page_size}"):
page, metadata = get_optimized_page(queryset, page_number, page_size)
```
### Running Benchmarks
```bash
# Run complete benchmark suite
python manage.py benchmark_performance
# Run specific benchmarks
python manage.py benchmark_performance --autocomplete-only
python manage.py benchmark_performance --listing-only
python manage.py benchmark_performance --pagination-only
# Run multiple iterations for statistical analysis
python manage.py benchmark_performance --iterations 10 --save
```
### Performance Baselines
#### Before Optimization
- **Average response time**: 2.5-4.0 seconds
- **Database queries per request**: 15-25 queries
- **Memory usage**: 150-200MB per request
- **Cache hit rate**: 45-60%
#### After Optimization
- **Average response time**: 0.5-1.2 seconds
- **Database queries per request**: 3-8 queries
- **Memory usage**: 75-100MB per request
- **Cache hit rate**: 85-95%
## Deployment Recommendations
### Production Environment Setup
#### 1. Database Configuration
```python
# settings/production.py
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'OPTIONS': {
'MAX_CONNS': 50,
'OPTIONS': {
'MAX_CONNS': 50,
'OPTIONS': {
'application_name': 'thrillwiki_production',
'default_transaction_isolation': 'read committed',
}
}
}
}
}
# Connection pooling
DATABASES['default']['CONN_MAX_AGE'] = 600
```
#### 2. Cache Configuration
```python
# Redis configuration for production
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': 'redis://redis-cluster:6379/1',
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
'CONNECTION_POOL_KWARGS': {
'max_connections': 50,
'retry_on_timeout': True,
},
'COMPRESSOR': 'django_redis.compressors.zlib.ZlibCompressor',
'IGNORE_EXCEPTIONS': True,
},
'TIMEOUT': 300,
'VERSION': 1,
}
}
```
#### 3. CDN and Static Files
```python
# CloudFlare Images configuration
CLOUDFLARE_IMAGES_BASE_URL = 'https://imagedelivery.net/your-account-id'
CLOUDFLARE_IMAGES_TOKEN = os.environ.get('CLOUDFLARE_IMAGES_TOKEN')
# Static files optimization
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
WHITENOISE_USE_FINDERS = True
WHITENOISE_AUTOREFRESH = True
```
#### 4. Application Server Configuration
```python
# Gunicorn configuration (gunicorn.conf.py)
bind = "0.0.0.0:8000"
workers = 4
worker_class = "gevent"
worker_connections = 1000
max_requests = 1000
max_requests_jitter = 100
preload_app = True
keepalive = 5
```
### Monitoring and Alerting
#### 1. Performance Monitoring
```python
# settings/monitoring.py
LOGGING = {
'version': 1,
'handlers': {
'performance': {
'level': 'INFO',
'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/performance.log',
'maxBytes': 10485760, # 10MB
'backupCount': 10,
},
},
'loggers': {
'query_optimization': {
'handlers': ['performance'],
'level': 'INFO',
},
'pagination_service': {
'handlers': ['performance'],
'level': 'INFO',
},
},
}
```
#### 2. Health Checks
```python
# Add to urls.py
path('health/', include('health_check.urls')),
# settings.py
HEALTH_CHECK = {
'DISK_USAGE_MAX': 90, # percent
'MEMORY_MIN': 100, # in MB
}
```
### Deployment Checklist
#### Pre-Deployment
- [ ] Run full benchmark suite and verify performance targets
- [ ] Apply database migrations in maintenance window
- [ ] Verify all indexes are created successfully
- [ ] Test cache connectivity and performance
- [ ] Run security audit on new code
#### Post-Deployment
- [ ] Monitor application performance metrics
- [ ] Verify database query performance
- [ ] Check cache hit rates and efficiency
- [ ] Monitor error rates and response times
- [ ] Validate user experience improvements
## Performance Monitoring
### Real-Time Monitoring
#### 1. Application Performance
```python
# Custom middleware for performance tracking
class PerformanceMonitoringMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
start_time = time.time()
initial_queries = len(connection.queries)
response = self.get_response(request)
duration = time.time() - start_time
query_count = len(connection.queries) - initial_queries
# Log performance metrics
logger.info(f"Request performance: {request.path} - {duration:.3f}s, {query_count} queries")
return response
```
#### 2. Database Performance
```sql
-- Monitor slow queries
SELECT query, mean_time, calls, total_time
FROM pg_stat_statements
WHERE mean_time > 100
ORDER BY mean_time DESC
LIMIT 10;
-- Monitor index usage
SELECT schemaname, tablename, attname, n_distinct, correlation
FROM pg_stats
WHERE tablename LIKE 'parks_%'
ORDER BY correlation DESC;
```
#### 3. Cache Performance
```python
# Cache monitoring dashboard
def get_cache_stats():
if hasattr(cache, '_cache') and hasattr(cache._cache, 'info'):
redis_info = cache._cache.info()
return {
'used_memory': redis_info.get('used_memory_human'),
'hit_rate': redis_info.get('keyspace_hits') / (redis_info.get('keyspace_hits') + redis_info.get('keyspace_misses')) * 100,
'connected_clients': redis_info.get('connected_clients'),
}
```
### Performance Alerts
#### 1. Response Time Alerts
```python
# Alert thresholds
PERFORMANCE_THRESHOLDS = {
'response_time_warning': 1.0, # 1 second
'response_time_critical': 3.0, # 3 seconds
'query_count_warning': 10, # 10 queries
'query_count_critical': 20, # 20 queries
'cache_hit_rate_warning': 80, # 80% hit rate
'cache_hit_rate_critical': 60, # 60% hit rate
}
```
#### 2. Monitoring Integration
```python
# Integration with monitoring services
def send_performance_alert(metric, value, threshold):
if settings.SENTRY_DSN:
sentry_sdk.capture_message(
f"Performance alert: {metric} = {value} (threshold: {threshold})",
level="warning"
)
if settings.SLACK_WEBHOOK_URL:
slack_alert(f"🚨 Performance Alert: {metric} exceeded threshold")
```
## Maintenance Guidelines
### Regular Maintenance Tasks
#### Weekly Tasks
- [ ] Review performance logs for anomalies
- [ ] Check cache hit rates and adjust timeouts if needed
- [ ] Monitor database query performance
- [ ] Verify image loading performance
#### Monthly Tasks
- [ ] Run comprehensive benchmark suite
- [ ] Analyze slow query logs and optimize
- [ ] Review and update cache strategies
- [ ] Check database index usage statistics
- [ ] Update performance documentation
#### Quarterly Tasks
- [ ] Review and optimize database indexes
- [ ] Audit and clean up unused cache keys
- [ ] Update performance benchmarks and targets
- [ ] Review and optimize CloudFlare Images usage
- [ ] Conduct load testing with realistic traffic patterns
### Performance Regression Prevention
#### 1. Automated Testing
```python
# Performance regression tests
class PerformanceRegressionTests(TestCase):
def test_park_listing_performance(self):
with track_queries("park_listing_test"):
response = self.client.get('/parks/')
self.assertEqual(response.status_code, 200)
# Assert performance thresholds
metrics = performance_monitor.metrics[-1]
self.assertLess(metrics.duration, 1.0) # Max 1 second
self.assertLess(metrics.query_count, 8) # Max 8 queries
```
#### 2. Code Review Guidelines
- Review all new database queries for N+1 patterns
- Ensure proper use of select_related and prefetch_related
- Verify cache invalidation strategies for model changes
- Check that new features use existing optimized services
#### 3. Performance Budget
```javascript
// Performance budget enforcement
const PERFORMANCE_BUDGET = {
firstContentfulPaint: 1.5, // seconds
largestContentfulPaint: 2.5, // seconds
cumulativeLayoutShift: 0.1,
totalJavaScriptSize: 500, // KB
totalImageSize: 2000, // KB
};
```
### Troubleshooting Common Issues
#### 1. High Response Times
```bash
# Check database performance
python manage.py dbshell -c "
SELECT query, mean_time, calls
FROM pg_stat_statements
WHERE mean_time > 100
ORDER BY mean_time DESC LIMIT 5;"
# Check cache performance
python manage.py shell -c "
from apps.parks.services.cache_service import CacheService;
print(CacheService.get_cache_stats())
"
```
#### 2. Memory Usage Issues
```bash
# Monitor memory usage
python manage.py benchmark_performance --iterations 1 | grep "Memory"
# Check for memory leaks
python -m memory_profiler manage.py runserver
```
#### 3. Cache Issues
```bash
# Clear specific cache prefixes
python manage.py shell -c "
from apps.parks.services.cache_service import CacheService;
CacheService.invalidate_related_caches('park')
"
# Warm up caches after deployment
python manage.py shell -c "
from apps.parks.services.cache_service import CacheService;
CacheService.warm_cache()
"
```
## Conclusion
The implemented performance optimizations provide significant improvements across all metrics:
- **85% reduction** in database queries through optimized queryset building
- **75% improvement** in response times through strategic caching
- **90% better pagination** performance for large datasets
- **Comprehensive monitoring** and benchmarking capabilities
- **Production-ready** deployment recommendations
These optimizations ensure the park listing page can scale effectively to handle larger datasets and increased user traffic while maintaining excellent user experience.
For questions or issues related to these optimizations, refer to the troubleshooting section or contact the development team.
---
**Last Updated**: September 23, 2025
**Version**: 1.0.0
**Author**: ThrillWiki Development Team

443
README.md
View File

@@ -1,87 +1,200 @@
# ThrillWiki Backend
# ThrillWiki Django + Vue.js Monorepo
Django REST API backend for the ThrillWiki monorepo.
A comprehensive theme park and roller coaster information system built with a modern monorepo architecture combining Django REST API backend with Vue.js frontend.
## 🏗️ Architecture
## 🏗️ Architecture Overview
This backend follows Django best practices with a modular app structure:
This project uses a monorepo structure that cleanly separates backend and frontend concerns while maintaining shared resources and documentation:
```
backend/
├── apps/ # Django applications
│ ├── accounts/ # User management
│ ├── parks/ # Theme park data
│ ├── rides/ # Ride information
── moderation/ # Content moderation
│ ├── location/ # Geographic data
│ ├── media/ # File management
│ ├── email_service/ # Email functionality
│ └── core/ # Core utilities
├── config/ # Django configuration
│ ├── django/ # Settings files
── settings/ # Modular settings
├── templates/ # Django templates
├── static/ # Static files
── tests/ # Test files
thrillwiki-monorepo/
├── backend/ # Django REST API (Port 8000)
│ ├── apps/ # Modular Django applications
│ ├── config/ # Django settings and configuration
│ ├── templates/ # Django templates
── static/ # Static assets
├── frontend/ # Vue.js SPA (Port 5174)
│ ├── src/ # Vue.js source code
│ ├── public/ # Static assets
│ └── dist/ # Build output
├── shared/ # Shared resources and documentation
│ ├── docs/ # Comprehensive documentation
── scripts/ # Development and deployment scripts
│ ├── config/ # Shared configuration
│ └── media/ # Shared media files
── architecture/ # Architecture documentation
└── profiles/ # Development profiles
```
## 🛠️ Technology Stack
- **Django 5.0+** - Web framework
- **Django REST Framework** - API framework
- **PostgreSQL** - Primary database
- **Redis** - Caching and sessions
- **UV** - Python package management
- **Celery** - Background task processing
## 🚀 Quick Start
### Prerequisites
- Python 3.11+
- [uv](https://docs.astral.sh/uv/) package manager
- PostgreSQL 14+
- Redis 6+
- **Python 3.11+** with [uv](https://docs.astral.sh/uv/) for backend dependencies
- **Node.js 18+** with [pnpm](https://pnpm.io/) for frontend dependencies
- **PostgreSQL 14+** (optional, defaults to SQLite for development)
- **Redis 6+** (optional, for caching and sessions)
### Setup
### Development Setup
1. **Install dependencies**
1. **Clone the repository**
```bash
git clone <repository-url>
cd thrillwiki-monorepo
```
2. **Install dependencies**
```bash
# Install frontend dependencies
pnpm install
# Install backend dependencies
cd backend && uv sync && cd ..
```
3. **Environment configuration**
```bash
# Copy environment files
cp .env.example .env
cp backend/.env.example backend/.env
cp frontend/.env.development frontend/.env.local
# Edit .env files with your settings
```
4. **Database setup**
```bash
cd backend
uv sync
```
2. **Environment configuration**
```bash
cp .env.example .env
# Edit .env with your settings
```
3. **Database setup**
```bash
uv run manage.py migrate
uv run manage.py createsuperuser
cd ..
```
4. **Start development server**
5. **Start development servers**
```bash
uv run manage.py runserver
# Start both servers concurrently
pnpm run dev
# Or start individually
pnpm run dev:frontend # Vue.js on :5174
pnpm run dev:backend # Django on :8000
```
## 📁 Project Structure Details
### Backend (`/backend`)
- **Django 5.0+** with REST Framework for API development
- **Modular app architecture** with separate apps for parks, rides, accounts, etc.
- **UV package management** for fast, reliable Python dependency management
- **PostgreSQL/SQLite** database with comprehensive entity relationships
- **Redis** for caching, sessions, and background tasks
- **Comprehensive API** with frontend serializers for camelCase conversion
### Frontend (`/frontend`)
- **Vue 3** with Composition API and `<script setup>` syntax
- **TypeScript** for type safety and better developer experience
- **Vite** for lightning-fast development and optimized production builds
- **Tailwind CSS** with custom design system and dark mode support
- **Pinia** for state management with modular stores
- **Vue Router** for client-side routing
- **Comprehensive UI component library** with shadcn-vue components
### Shared Resources (`/shared`)
- **Documentation** - Comprehensive guides and API documentation
- **Development scripts** - Automated setup, build, and deployment scripts
- **Configuration** - Shared Docker, CI/CD, and infrastructure configs
- **Media management** - Centralized media file handling and optimization
## 🛠️ Development Workflow
### Available Scripts
```bash
# Development
pnpm run dev # Start both servers concurrently
pnpm run dev:frontend # Frontend only (:5174)
pnpm run dev:backend # Backend only (:8000)
# Building
pnpm run build # Build frontend for production
pnpm run build:staging # Build for staging environment
pnpm run build:production # Build for production environment
# Testing
pnpm run test # Run all tests
pnpm run test:frontend # Frontend unit and E2E tests
pnpm run test:backend # Backend unit and integration tests
# Code Quality
pnpm run lint # Lint all code
pnpm run type-check # TypeScript type checking
# Setup and Maintenance
pnpm run install:all # Install all dependencies
./shared/scripts/dev/setup-dev.sh # Full development setup
./shared/scripts/dev/start-all.sh # Start all services
```
### Backend Development
```bash
cd backend
# Django management commands
uv run manage.py migrate
uv run manage.py makemigrations
uv run manage.py createsuperuser
uv run manage.py collectstatic
# Testing and quality
uv run manage.py test
uv run black . # Format code
uv run flake8 . # Lint code
uv run isort . # Sort imports
```
### Frontend Development
```bash
cd frontend
# Vue.js development
pnpm run dev # Start dev server
pnpm run build # Production build
pnpm run preview # Preview production build
pnpm run test:unit # Vitest unit tests
pnpm run test:e2e # Playwright E2E tests
pnpm run lint # ESLint
pnpm run type-check # TypeScript checking
```
## 🔧 Configuration
### Environment Variables
Required environment variables:
#### Root `.env`
```bash
# Database
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
REDIS_URL=redis://localhost:6379
# Django
# Security
SECRET_KEY=your-secret-key
DEBUG=True
# API Configuration
API_BASE_URL=http://localhost:8000/api
```
#### Backend `.env`
```bash
# Django Settings
DJANGO_SETTINGS_MODULE=config.django.local
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1
# Database
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
# Redis
REDIS_URL=redis://localhost:6379
@@ -90,140 +203,142 @@ REDIS_URL=redis://localhost:6379
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=your-email@gmail.com
EMAIL_HOST_PASSWORD=your-app-password
```
### Settings Structure
- `config/django/base.py` - Base settings
- `config/django/local.py` - Development settings
- `config/django/production.py` - Production settings
- `config/django/test.py` - Test settings
## 📁 Apps Overview
### Core Apps
- **accounts** - User authentication and profile management
- **parks** - Theme park models and operations
- **rides** - Ride information and relationships
- **core** - Shared utilities and base classes
### Support Apps
- **moderation** - Content moderation workflows
- **location** - Geographic data and services
- **media** - File upload and management
- **email_service** - Email sending and templates
## 🔌 API Endpoints
Base URL: `http://localhost:8000/api/`
### Authentication
- `POST /auth/login/` - User login
- `POST /auth/logout/` - User logout
- `POST /auth/register/` - User registration
### Parks
- `GET /parks/` - List parks
- `GET /parks/{id}/` - Park details
- `POST /parks/` - Create park (admin)
### Rides
- `GET /rides/` - List rides
- `GET /rides/{id}/` - Ride details
- `GET /parks/{park_id}/rides/` - Rides by park
## 🧪 Testing
#### Frontend `.env.local`
```bash
# Run all tests
uv run manage.py test
# API Configuration
VITE_API_BASE_URL=http://localhost:8000/api
# Run specific app tests
uv run manage.py test apps.parks
# Development
VITE_APP_TITLE=ThrillWiki (Development)
# Run with coverage
uv run coverage run manage.py test
uv run coverage report
# Feature Flags
VITE_ENABLE_DEBUG=true
```
## 🔧 Management Commands
## 📊 Key Features
Custom management commands:
### Backend Features
- **Comprehensive Park Database** - Detailed information about theme parks worldwide
- **Extensive Ride Database** - Complete roller coaster and ride information
- **User Management** - Authentication, profiles, and permissions
- **Content Moderation** - Review and approval workflows
- **API Documentation** - Auto-generated OpenAPI/Swagger docs
- **Background Tasks** - Celery integration for long-running processes
- **Caching Strategy** - Redis-based caching for performance
- **Search Functionality** - Full-text search across all content
```bash
# Import park data
uv run manage.py import_parks data/parks.json
### Frontend Features
- **Responsive Design** - Mobile-first approach with Tailwind CSS
- **Dark Mode Support** - Complete dark/light theme system
- **Real-time Search** - Instant search with debouncing and highlighting
- **Interactive Maps** - Park and ride location visualization
- **Photo Galleries** - High-quality image management
- **User Dashboard** - Personalized content and contributions
- **Progressive Web App** - PWA capabilities for mobile experience
- **Accessibility** - WCAG 2.1 AA compliance
# Generate test data
uv run manage.py generate_test_data
## 📖 Documentation
# Clean up expired sessions
uv run manage.py clearsessions
```
### Core Documentation
- **[Backend Documentation](./backend/README.md)** - Django setup and API details
- **[Frontend Documentation](./frontend/README.md)** - Vue.js setup and development
- **[API Documentation](./shared/docs/api/README.md)** - Complete API reference
- **[Development Workflow](./shared/docs/development/workflow.md)** - Daily development processes
## 📊 Database
### Architecture & Deployment
- **[Architecture Overview](./architecture/)** - System design and decisions
- **[Deployment Guide](./shared/docs/deployment/)** - Production deployment instructions
- **[Development Scripts](./shared/scripts/)** - Automation and tooling
### Entity Relationships
- **Parks** have Operators (required) and PropertyOwners (optional)
- **Rides** belong to Parks and may have Manufacturers/Designers
- **Users** can create submissions and moderate content
### Migrations
```bash
# Create migrations
uv run manage.py makemigrations
# Apply migrations
uv run manage.py migrate
# Show migration status
uv run manage.py showmigrations
```
## 🔐 Security
- CORS configured for frontend integration
- CSRF protection enabled
- JWT token authentication
- Rate limiting on API endpoints
- Input validation and sanitization
## 📈 Performance
- Database query optimization
- Redis caching for frequent queries
- Background task processing with Celery
- Database connection pooling
### Additional Resources
- **[Contributing Guide](./CONTRIBUTING.md)** - How to contribute to the project
- **[Code of Conduct](./CODE_OF_CONDUCT.md)** - Community guidelines
- **[Security Policy](./SECURITY.md)** - Security reporting and policies
## 🚀 Deployment
See the [Deployment Guide](../shared/docs/deployment/) for production setup.
### Development Environment
```bash
# Quick start with all services
./shared/scripts/dev/start-all.sh
## 🐛 Debugging
# Full development setup
./shared/scripts/dev/setup-dev.sh
```
### Development Tools
### Production Deployment
```bash
# Build all components
./shared/scripts/build/build-all.sh
- Django Debug Toolbar
- Django Extensions
- Silk profiler for performance analysis
# Deploy to production
./shared/scripts/deploy/deploy.sh
```
### Logging
See [Deployment Guide](./shared/docs/deployment/) for detailed production setup instructions.
Logs are written to:
- Console (development)
- Files in `logs/` directory (production)
- External logging service (production)
## 🧪 Testing Strategy
### Backend Testing
- **Unit Tests** - Individual function and method testing
- **Integration Tests** - API endpoint and database interaction testing
- **E2E Tests** - Full user journey testing with Selenium
### Frontend Testing
- **Unit Tests** - Component and utility function testing with Vitest
- **Integration Tests** - Component interaction testing
- **E2E Tests** - User journey testing with Playwright
### Code Quality
- **Linting** - ESLint for JavaScript/TypeScript, Flake8 for Python
- **Type Checking** - TypeScript for frontend, mypy for Python
- **Code Formatting** - Prettier for frontend, Black for Python
## 🤝 Contributing
1. Follow Django coding standards
2. Write tests for new features
3. Update documentation
4. Run linting: `uv run flake8 .`
5. Format code: `uv run black .`
We welcome contributions! Please see our [Contributing Guide](./CONTRIBUTING.md) for details on:
1. **Development Setup** - Getting your development environment ready
2. **Code Standards** - Coding conventions and best practices
3. **Pull Request Process** - How to submit your changes
4. **Issue Reporting** - How to report bugs and request features
### Quick Contribution Start
```bash
# Fork and clone the repository
git clone https://github.com/your-username/thrillwiki-monorepo.git
cd thrillwiki-monorepo
# Set up development environment
./shared/scripts/dev/setup-dev.sh
# Create a feature branch
git checkout -b feature/your-feature-name
# Make your changes and test
pnpm run test
# Submit a pull request
```
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details.
## 🙏 Acknowledgments
- **Theme Park Community** - For providing data and inspiration
- **Open Source Contributors** - For the amazing tools and libraries
- **Vue.js and Django Communities** - For excellent documentation and support
## 📞 Support
- **Issues** - [GitHub Issues](https://github.com/your-repo/thrillwiki-monorepo/issues)
- **Discussions** - [GitHub Discussions](https://github.com/your-repo/thrillwiki-monorepo/discussions)
- **Documentation** - [Project Wiki](https://github.com/your-repo/thrillwiki-monorepo/wiki)
---
**Built with ❤️ for the theme park and roller coaster community**

View File

@@ -1,231 +0,0 @@
# Visual Regression Testing Report
## Cotton Components vs Original Include Components
**Date:** September 21, 2025
**Test Domain:** https://d6d61dac-164d-45dd-929f-7dcdfd771b64-00-1bpe9dzxxnshv.worf.replit.dev
**Test Status:** ✅ PASSED - Zero Visual Differences Confirmed
---
## Executive Summary
Comprehensive visual regression testing has been performed comparing original Django include-based components with new Cotton component implementations. **All tests passed with zero visual differences detected.** The Cotton components preserve exact HTML output, CSS classes, styling, and interactive functionality.
## Test Pages Verified
1. **Button Component Test Page:** `/test-button/`
2. **Auth Modal Component Test Page:** `/test-auth-modal/`
## Components Tested
### 1. Button Component (`<c-button>`)
**Original:** `{% include 'components/ui/button.html' %}`
**Cotton:** `<c-button>`
#### ✅ Visual Parity Confirmed
**Variants Tested:**
- ✅ Default variant - Identical blue primary styling
- ✅ Destructive variant - Identical red warning styling
- ✅ Outline variant - Identical border-only styling
- ✅ Secondary variant - Identical gray secondary styling
- ✅ Ghost variant - Identical transparent background styling
- ✅ Link variant - Identical underlined link styling
**Sizes Tested:**
- ✅ Default size (h-10 px-4 py-2)
- ✅ Small size (h-9 rounded-md px-3)
- ✅ Large size (h-11 rounded-md px-8)
- ✅ Icon size (h-10 w-10)
**Additional Features:**
- ✅ Icons (left and right) - Identical positioning and styling
- ✅ HTMX attributes (hx-get, hx-post, hx-target, hx-swap) - Preserved exactly
- ✅ Alpine.js directives (x-data, x-on) - Functional and identical
- ✅ Custom classes - Applied correctly
- ✅ Type attributes (submit, button) - Preserved
- ✅ Disabled state - Identical styling and behavior
- ✅ Legacy underscore props (hx_get) vs modern hyphenated (hx-get) - Both supported
#### Technical Analysis
```html
<!-- Both produce identical HTML structure -->
<button class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2">
Button Text
</button>
```
### 2. Input Component (`<c-input>`)
**Original:** `{% include 'components/ui/input.html' %}`
**Cotton:** `<c-input>`
#### ✅ Visual Parity Confirmed
**Features Tested:**
- ✅ Text input styling - Identical border, padding, focus states
- ✅ Placeholder text - Identical muted foreground styling
- ✅ Disabled state - Identical opacity and cursor styling
- ✅ Required field validation - Functional
- ✅ HTMX attributes - Preserved exactly
- ✅ Alpine.js x-model binding - Functional
### 3. Card Component (`<c-card>`)
**Original:** `{% include 'components/ui/card.html' %}`
**Cotton:** `<c-card>`
#### ✅ Visual Parity Confirmed
**Features Tested:**
- ✅ Card container styling - Identical border, shadow, and background
- ✅ Header content - Identical padding and typography
- ✅ Body content - Identical spacing and layout
- ✅ Footer content - Identical positioning
- ✅ Slot content mechanism - Functional replacement for include parameters
### 4. Auth Modal Component (`<c-auth_modal>`)
**Original:** `{% include 'components/auth/auth-modal.html' %}`
**Cotton:** `<c-auth_modal>`
#### ✅ Visual Parity Confirmed
**Modal Behavior:**
- ✅ Modal opening animation - Identical fade-in and scale transitions
- ✅ Modal closing behavior - ESC key, overlay click, X button all work identically
- ✅ Background overlay - Identical blur and opacity effects
- ✅ Modal positioning - Identical center alignment and responsive behavior
**Form Functionality:**
- ✅ Login/Register form switching - Identical behavior and animations
- ✅ Form field styling - Identical input styling and validation states
- ✅ Password visibility toggle - Eye icon functionality preserved
- ✅ Social provider buttons - Identical styling and layout
- ✅ Error message display - Identical styling and positioning
- ✅ Loading states - Spinner animations and disabled states work identically
**Alpine.js Integration:**
- ✅ x-data="authModal" - Component initialization preserved
- ✅ x-show directives - Conditional display logic identical
- ✅ x-transition animations - Fade and scale effects identical
- ✅ Event handlers (@click, @keydown.escape) - All functional
- ✅ Template loops (x-for) - Social provider rendering identical
- ✅ State management - Form switching and error handling identical
## Interactive Functionality Testing
### Button Interactions
- ✅ Hover states - Color transitions identical
- ✅ Click events - JavaScript handlers functional
- ✅ HTMX requests - Network requests triggered correctly
- ✅ Alpine.js integration - State changes handled identically
### Modal Interactions
- ✅ Keyboard navigation - TAB, ESC, ENTER all work
- ✅ Focus management - Focus trapping identical
- ✅ Form validation - Client-side validation preserved
- ✅ Social authentication - Button click handlers functional
## CSS Classes Analysis
### Identical Class Application
All components generate identical CSS class strings:
**Button Base Classes:**
```css
inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50
```
**Input Base Classes:**
```css
flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50
```
## HTMX Attribute Preservation
### Verified HTMX Attributes
-`hx-get` - Preserved in both underscore and hyphenated formats
-`hx-post` - Preserved in both underscore and hyphenated formats
-`hx-target` - Element targeting preserved
-`hx-swap` - Swap strategies preserved
-`hx-trigger` - Event triggers preserved
-`hx-include` - Form inclusion preserved
## Alpine.js Directive Preservation
### Verified Alpine.js Directives
-`x-data` - Component initialization preserved
-`x-show` - Conditional display preserved
-`x-transition` - Animation configurations preserved
-`x-model` - Two-way data binding preserved
-`x-on/@` - Event handlers preserved
-`x-for` - Template loops preserved
-`x-init` - Initialization logic preserved
## Legacy Compatibility
### Underscore vs Hyphenated Attributes
Cotton components support both legacy underscore props and modern hyphenated attributes:
-`hx_get` and `hx-get` both work
-`hx_post` and `hx-post` both work
-`x_data` and `x-data` both work
- ✅ Backward compatibility preserved
## Performance Analysis
### Rendering Performance
- ✅ No measurable performance difference in rendering time
- ✅ HTML output size identical
- ✅ No additional HTTP requests
- ✅ Client-side JavaScript behavior unchanged
## Browser Compatibility
### Tested Behaviors
- ✅ Chrome - All features functional
- ✅ Firefox - All features functional
- ✅ Safari - All features functional
- ✅ Mobile responsive behavior identical
## Test Results Summary
| Component | Visual Parity | Functionality | HTMX | Alpine.js | CSS Classes | Status |
|-----------|---------------|---------------|------|-----------|-------------|---------|
| Button | ✅ Identical | ✅ Preserved | ✅ Working | ✅ Working | ✅ Identical | ✅ PASS |
| Input | ✅ Identical | ✅ Preserved | ✅ Working | ✅ Working | ✅ Identical | ✅ PASS |
| Card | ✅ Identical | ✅ Preserved | ✅ Working | ✅ Working | ✅ Identical | ✅ PASS |
| Auth Modal | ✅ Identical | ✅ Preserved | ✅ Working | ✅ Working | ✅ Identical | ✅ PASS |
## Differences Found
**Total Visual Differences: 0**
**Total Functional Differences: 0**
**Total Breaking Changes: 0**
## Recommendations
1.**Proceed with Cotton component implementation** - Zero breaking changes detected
2.**Migration is safe** - All functionality preserved exactly
3.**Template updates can proceed** - Components are production-ready
4.**Developer experience improved** - Cotton syntax is cleaner and more maintainable
## Conclusion
The Cotton component implementation has achieved **100% visual and functional parity** with the original include-based components. All tests pass with zero differences detected. The migration to Cotton components can proceed with confidence as:
- HTML output is identical
- CSS styling is preserved exactly
- Interactive functionality works identically
- HTMX and Alpine.js integration is preserved
- Legacy compatibility is maintained
- Performance characteristics are unchanged
**Status: ✅ APPROVED FOR PRODUCTION USE**
---
*Test conducted on September 21, 2025*
*All components verified on test domain: d6d61dac-164d-45dd-929f-7dcdfd771b64-00-1bpe9dzxxnshv.worf.replit.dev*

File diff suppressed because it is too large Load Diff

View File

@@ -1,77 +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"),
("django_cloudflareimages_toolkit", "0001_initial"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="userprofile",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="userprofile",
name="update_update",
),
migrations.AddField(
model_name="userprofile",
name="avatar",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
migrations.AddField(
model_name="userprofileevent",
name="avatar",
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
pgtrigger.migrations.AddTrigger(
model_name="userprofile",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_userprofileevent" ("avatar_id", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar_id", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
hash="a7ecdb1ac2821dea1fef4ec917eeaf6b8e4f09c8",
operation="INSERT",
pgid="pgtrigger_insert_insert_c09d7",
table="accounts_userprofile",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="userprofile",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_userprofileevent" ("avatar_id", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar_id", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
hash="81607e492ffea2a4c741452b860ee660374cc01d",
operation="UPDATE",
pgid="pgtrigger_update_update_87ef6",
table="accounts_userprofile",
when="AFTER",
),
),
),
]

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,119 +0,0 @@
# Enhanced ThrillWiki Header Icons Sizing Prompt
```xml
<instructions>
Increase the size of the theme toggle icon and user profile icon in ThrillWiki's header navigation. The icons should be more prominent and touch-friendly while maintaining visual harmony with the existing Django Cotton header component design. Update the CSS classes and ensure proper scaling across different screen sizes using ThrillWiki's responsive design patterns.
</instructions>
<thrillwiki_context>
ThrillWiki uses Django Cotton templating for the header component, likely located in a `header.html` template or Cotton component. The header contains navigation elements, theme toggle functionality (probably using AlpineJS for state management), and user authentication status indicators. The current icon sizing may be using utility classes or custom CSS within the Django project structure.
Technologies involved:
- Django Cotton for templating
- AlpineJS for theme toggle interactivity
- CSS/Tailwind for styling and responsive design
- Responsive design patterns for mobile usability
</thrillwiki_context>
<example>
Current header structure likely resembles:
```html
<!-- Django Cotton header component -->
<header class="header-container">
<nav class="nav-wrapper">
<!-- Theme toggle icon (current: small) -->
<button @click="toggleTheme()" class="theme-toggle">
<svg class="w-4 h-4"><!-- Theme icon --></svg>
</button>
<!-- User profile icon (current: small) -->
<div class="user-menu">
<svg class="w-4 h-4"><!-- User icon --></svg>
</div>
</nav>
</header>
```
Enhanced version should increase to:
```html
<!-- Updated with larger icons -->
<button @click="toggleTheme()" class="theme-toggle">
<svg class="w-6 h-6 md:w-7 md:h-7"><!-- Larger theme icon --></svg>
</button>
<div class="user-menu">
<svg class="w-6 h-6 md:w-7 md:h-7"><!-- Larger user icon --></svg>
</div>
```
</example>
<variables>
<current_icon_size>w-4 h-4 (16px)</current_icon_size>
<target_icon_size>w-6 h-6 (24px) mobile, w-7 h-7 (28px) desktop</target_icon_size>
<component_location>header.html, base.html, or dedicated Cotton component</component_location>
<styling_approach>Utility classes with responsive modifiers</styling_approach>
<interactivity>AlpineJS theme toggle, Django user authentication</interactivity>
</variables>
<thinking>
The header icons need to be enlarged while considering:
1. Touch accessibility (minimum 44px touch targets)
2. Visual balance with other header elements
3. Responsive behavior across devices
4. Consistency with ThrillWiki's design system
5. Proper spacing to avoid crowding
6. Potential impact on mobile header layout
Development approach should:
- Locate the header template/component
- Identify current icon sizing classes
- Update with responsive sizing utilities
- Test across breakpoints
- Ensure touch targets meet accessibility standards
</thinking>
<checkpoint_approach>
**Phase 1: Locate & Analyze**
- Find header template in Django Cotton components
- Identify current icon classes and sizing
- Document existing responsive behavior
**Phase 2: Update Sizing**
- Replace icon size classes with larger variants
- Add responsive modifiers for different screen sizes
- Maintain proper spacing and alignment
**Phase 3: Test & Refine**
- Test header layout on mobile, tablet, desktop
- Verify theme toggle functionality still works
- Check user menu interactions
- Ensure accessibility compliance (touch targets)
**Phase 4: Optimize**
- Adjust spacing if needed for visual balance
- Confirm consistency with ThrillWiki design patterns
- Test with different user states (logged in/out)
</checkpoint_approach>
<debugging_context>
Common issues to watch for:
- Icons becoming too large and breaking header layout
- Responsive breakpoints causing icon jumping
- AlpineJS theme toggle losing functionality after DOM changes
- User menu positioning issues with larger icons
- Touch target overlapping with adjacent elements
Django/HTMX considerations:
- Ensure icon changes don't break HTMX partial updates
- Verify Django Cotton component inheritance
- Check if icons are SVGs, icon fonts, or images
</debugging_context>
<testing_strategy>
1. **Visual Testing**: Check header appearance across screen sizes
2. **Functional Testing**: Verify theme toggle and user menu still work
3. **Accessibility Testing**: Confirm touch targets meet 44px minimum
4. **Cross-browser Testing**: Ensure consistent rendering
5. **Mobile Testing**: Test on actual mobile devices for usability
</testing_strategy>
```

View File

@@ -1,147 +0,0 @@
# Enhanced ThrillWiki Park Listing Page - Optimized Prompt
```xml
<instructions>
Create an improved park listing page for ThrillWiki that prioritizes user experience with intelligent filtering, real-time autocomplete search, and clean pagination. Build using Django Cotton templates, HTMX for dynamic interactions, and AlpineJS for reactive filtering components. Focus on accessibility, performance, and intuitive navigation without infinite scroll complexity.
Key requirements:
- Fast, responsive autocomplete search leveraging available database fields
- Multi-criteria filtering with live updates based on existing Park model attributes
- Clean pagination with proper Django pagination controls
- Optimized park card layout using CloudFlare Images
- Accessible design following WCAG guidelines
- Mobile-first responsive approach
</instructions>
<thrillwiki_context>
Working with ThrillWiki's existing Django infrastructure:
- Unknown Park model structure - will need to examine current fields and relationships
- Potential integration with PostGIS if geographic data exists
- Unknown filtering criteria - will discover available Park attributes for filtering
- Unknown review/rating system - will check if rating data is available
The page should integrate with:
- Django Cotton templating system for consistent components
- HTMX endpoints for search and filtering without full page reloads
- AlpineJS for client-side filter state management
- CloudFlare Images for optimized park images (if image fields exist)
- Existing ThrillWiki URL patterns and view structure
</thrillwiki_context>
<example>
Park listing page structure (adaptable based on discovered model fields):
```html
<!-- Search and Filter Section -->
<div x-data="parkFilters()" class="park-search-container">
<c-search-autocomplete
hx-get="/api/parks/search/"
hx-trigger="input changed delay:300ms"
placeholder="Search parks..."
/>
<c-filter-panel>
<!-- Filters will be determined by available Park model fields -->
<div class="filter-options" x-show="showFilters">
<!-- Dynamic filter generation based on model inspection -->
</div>
</c-filter-panel>
</div>
<!-- Results Section -->
<div id="park-results" hx-get="/parks/list/" class="park-grid">
<!-- Park cards will display available fields from Park model -->
<c-park-card v-for="park in parks" :park="park" :key="park.id">
<!-- Card content based on discovered model structure -->
</c-park-card>
</div>
<!-- Pagination -->
<c-pagination
:current-page="currentPage"
:total-pages="totalPages"
hx-get="/parks/list/"
hx-target="#park-results"
/>
```
Expected development approach:
1. Examine existing Park model to understand available fields
2. Identify searchable and filterable attributes
3. Design search/filter UI based on discovered data structure
4. Implement pagination with Django's built-in Paginator
5. Optimize queries and add HTMX interactions
</example>
<variables>
<django_models>Park (structure to be discovered), related models TBD</django_models>
<search_technologies>PostgreSQL full-text search, PostGIS if geographic fields exist</search_technologies>
<ui_framework>Django Cotton + HTMX + AlpineJS</ui_framework>
<image_optimization>CloudFlare Images (if image fields exist in Park model)</image_optimization>
<pagination_style>Traditional pagination with Django Paginator</pagination_style>
<accessibility_level>WCAG 2.1 AA compliance</accessibility_level>
<discovery_required>Park model fields, existing views/URLs, current template structure</discovery_required>
</variables>
<thinking>
Since we don't know the Park model structure, the development approach needs to be discovery-first:
1. **Model Discovery**: First step must be examining the Park model to understand:
- Available fields for display (name, description, etc.)
- Searchable text fields
- Filterable attributes (categories, status, etc.)
- Geographic data (if PostGIS integration exists)
- Image fields (for CloudFlare Images optimization)
- Relationship fields (foreign keys, many-to-many)
2. **Search Strategy**: Build search functionality based on discovered text fields
- Use Django's full-text search capabilities
- Add PostGIS spatial search if location fields exist
- Implement autocomplete based on available searchable fields
3. **Filter Design**: Create filters dynamically based on model attributes
- Categorical fields become dropdown/checkbox filters
- Numeric fields become range filters
- Boolean fields become toggle filters
- Date fields become date range filters
4. **Display Optimization**: Design park cards using available fields
- Prioritize essential information (name, basic details)
- Use CloudFlare Images if image fields exist
- Handle cases where optional fields might be empty
5. **Performance Considerations**:
- Use Django's select_related and prefetch_related based on discovered relationships
- Add database indexes for commonly searched/filtered fields
- Implement efficient pagination
The checkpoint approach will be:
- Checkpoint 1: Discover and document Park model structure
- Checkpoint 2: Build basic listing with pagination
- Checkpoint 3: Add search functionality based on available fields
- Checkpoint 4: Implement filters based on model attributes
- Checkpoint 5: Add HTMX interactions and optimize performance
- Checkpoint 6: Polish UI/UX and add accessibility features
</thinking>
<development_checkpoints>
1. **Discovery Phase**: Examine Park model, existing views, and current templates
2. **Basic Listing**: Create paginated park list with Django Cotton templates
3. **Search Implementation**: Add autocomplete search based on available text fields
4. **Filter System**: Build dynamic filters based on discovered model attributes
5. **HTMX Integration**: Add dynamic interactions without page reloads
6. **Optimization**: Performance tuning, image optimization, accessibility
7. **Testing**: Cross-browser testing, mobile responsiveness, user experience validation
</development_checkpoints>
<discovery_questions>
Before implementation, investigate:
1. What fields does the Park model contain?
2. Are there geographic/location fields that could leverage PostGIS?
3. What relationships exist (foreign keys to Location, Category, etc.)?
4. Is there a rating/review system connected to parks?
5. What image fields exist and how are they currently handled?
6. What existing views and URL patterns are in place?
7. What search functionality currently exists?
8. What Django Cotton components are already available?
</discovery_questions>
```

View File

@@ -1,55 +0,0 @@
<div class="flex gap-8">
<!-- Left Column -->
<div class="flex-1 space-y-4 min-w-0">
<a href="/parks/" class="flex items-start gap-3 p-3 rounded-md hover:bg-accent transition-colors group" @click="open = false">
<i class="fas fa-map-marker-alt w-4 h-4 mt-0.5 text-muted-foreground group-hover:text-foreground flex-shrink-0"></i>
<div class="min-w-0 flex-1">
<h3 class="font-medium text-sm mb-1 leading-tight">Parks</h3>
<p class="text-xs text-muted-foreground leading-relaxed">Explore theme parks worldwide</p>
</div>
</a>
<a href="/rides/manufacturers/" class="flex items-start gap-3 p-3 rounded-md hover:bg-accent transition-colors group" @click="open = false">
<i class="fas fa-wrench w-4 h-4 mt-0.5 text-muted-foreground group-hover:text-foreground flex-shrink-0"></i>
<div class="min-w-0 flex-1">
<h3 class="font-medium text-sm mb-1 leading-tight">Manufacturers</h3>
<p class="text-xs text-muted-foreground leading-relaxed">Ride and attraction manufacturers</p>
</div>
</a>
<a href="/parks/operators/" class="flex items-start gap-3 p-3 rounded-md hover:bg-accent transition-colors group" @click="open = false">
<i class="fas fa-users w-4 h-4 mt-0.5 text-muted-foreground group-hover:text-foreground flex-shrink-0"></i>
<div class="min-w-0 flex-1">
<h3 class="font-medium text-sm mb-1 leading-tight">Operators</h3>
<p class="text-xs text-muted-foreground leading-relaxed">Theme park operating companies</p>
</div>
</a>
</div>
<!-- Right Column -->
<div class="flex-1 space-y-4 min-w-0">
<a href="/rides/" class="flex items-start gap-3 p-3 rounded-md hover:bg-accent transition-colors group" @click="open = false">
<i class="fas fa-rocket w-4 h-4 mt-0.5 text-muted-foreground group-hover:text-foreground flex-shrink-0"></i>
<div class="min-w-0 flex-1">
<h3 class="font-medium text-sm mb-1 leading-tight">Rides</h3>
<p class="text-xs text-muted-foreground leading-relaxed">Discover rides and attractions</p>
</div>
</a>
<a href="/rides/designers/" class="flex items-start gap-3 p-3 rounded-md hover:bg-accent transition-colors group" @click="open = false">
<i class="fas fa-drafting-compass w-4 h-4 mt-0.5 text-muted-foreground group-hover:text-foreground flex-shrink-0"></i>
<div class="min-w-0 flex-1">
<h3 class="font-medium text-sm mb-1 leading-tight">Designers</h3>
<p class="text-xs text-muted-foreground leading-relaxed">Ride designers and architects</p>
</div>
</a>
<a href="#" class="flex items-start gap-3 p-3 rounded-md hover:bg-accent transition-colors group" @click="open = false">
<i class="fas fa-trophy w-4 h-4 mt-0.5 text-muted-foreground group-hover:text-foreground flex-shrink-0"></i>
<div class="min-w-0 flex-1">
<h3 class="font-medium text-sm mb-1 leading-tight">Top Lists</h3>
<p class="text-xs text-muted-foreground leading-relaxed">Community rankings and favorites</p>
</div>
</a>
</div>
</div>

View File

@@ -1,74 +0,0 @@
Alpine components script is loading... alpine-components.js:10:9
getEmbedInfo content.js:388:11
NO OEMBED content.js:456:11
Registering Alpine.js components... alpine-components.js:24:11
Alpine.js components registered successfully alpine-components.js:734:11
downloadable font: Glyph bbox was incorrect (glyph ids 2 3 5 8 9 10 11 12 14 17 19 21 22 32 34 35 39 40 43 44 45 46 47 49 51 52 54 56 57 58 60 61 62 63 64 65 67 68 69 71 74 75 76 77 79 86 89 91 96 98 99 100 102 103 109 110 111 113 116 117 118 124 127 128 129 130 132 133 134 137 138 140 142 143 145 146 147 155 156 159 160 171 172 173 177 192 201 202 203 204 207 208 209 210 225 231 233 234 235 238 239 243 244 246 252 253 254 256 259 261 262 268 269 278 279 280 281 285 287 288 295 296 302 303 304 305 307 308 309 313 315 322 324 353 355 356 357 360 362 367 370 371 376 390 396 397 398 400 403 404 407 408 415 416 417 418 423 424 425 427 428 432 433 434 435 436 439 451 452 455 461 467 470 471 482 483 485 489 491 496 499 500 505 514 529 532 541 542 543 547 549 551 553 554 555 556 557 559 579 580 581 582 584 591 592 593 594 595 596 597 600 601 608 609 614 615 622 624 649 658 659 662 664 673 679 680 681 682 684 687 688 689 692 693 694 695 696 698 699 700 702 708 710 711 712 714 716 719 723 724 727 728 729 731 732 733 739 750 751 754 755 756 758 759 761 762 763 766 770 776 778 781 792 795 798 800 802 803 807 808 810 813 818 822 823 826 834 837 854 860 861 862 863 866 867 871 872 874 875 881 882 883 886 892 894 895 897 898 900 901 902 907 910 913 915 917 920 927 936 937 943 945 946 947 949 950 951 954 955 956 958 961 962 964 965 966 968 969 970 974 976 978 980 981 982 985 986 991 992 998 1000 1001 1007 1008 1009 1010 1014 1016 1018 1020 1022 1023 1024 1027 1028 1033 1034 1035 1036 1037 1040 1041 1044 1045 1047 1048 1049 1053 1054 1055 1056 1057 1059 1061 1063 1064 1065 1072 1074 1075 1078 1079 1080 1081 1085 1086 1087 1088 1093 1095 1099 1100 1111 1112 1115 1116 1117 1120 1121 1122 1123 1124 1125) (font-family: "Font Awesome 6 Free" style:normal weight:900 stretch:100 src index:0) source: https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-solid-900.woff2
GET
https://d6d61dac-164d-45dd-929f-7dcdfd771b64-00-1bpe9dzxxnshv.worf.replit.dev/favicon.ico
[HTTP/1.1 404 Not Found 57ms]
Error in parsing value for -webkit-text-size-adjust. Declaration dropped. tailwind.css:162:31
Expected declaration but found @apply. Skipped to next declaration. components.css:137:9
Expected declaration but found @apply. Skipped to next declaration. components.css:141:9
Expected declaration but found @apply. Skipped to next declaration. components.css:145:9
Expected declaration but found @apply. Skipped to next declaration. components.css:149:9
Expected declaration but found @apply. Skipped to next declaration. components.css:153:9
Expected declaration but found @apply. Skipped to next declaration. components.css:157:9
Expected declaration but found @apply. Skipped to next declaration. components.css:161:9
Expected declaration but found @apply. Skipped to next declaration. components.css:165:9
Expected declaration but found @apply. Skipped to next declaration. components.css:169:9
Expected declaration but found @apply. Skipped to next declaration. components.css:173:9
Expected declaration but found @apply. Skipped to next declaration. components.css:178:9
Expected declaration but found @apply. Skipped to next declaration. components.css:182:9
Expected declaration but found @apply. Skipped to next declaration. components.css:186:9
Expected declaration but found @apply. Skipped to next declaration. components.css:190:9
Expected declaration but found @apply. Skipped to next declaration. components.css:194:9
Expected declaration but found @apply. Skipped to next declaration. components.css:198:9
Expected declaration but found @apply. Skipped to next declaration. components.css:203:9
Expected declaration but found @apply. Skipped to next declaration. components.css:208:9
Expected declaration but found @apply. Skipped to next declaration. components.css:212:9
Expected declaration but found @apply. Skipped to next declaration. components.css:216:9
Expected declaration but found @apply. Skipped to next declaration. components.css:220:9
Expected declaration but found @apply. Skipped to next declaration. components.css:225:9
Expected declaration but found @apply. Skipped to next declaration. components.css:229:9
Expected declaration but found @apply. Skipped to next declaration. components.css:234:9
Expected declaration but found @apply. Skipped to next declaration. components.css:238:9
Expected declaration but found @apply. Skipped to next declaration. components.css:242:9
Expected declaration but found @apply. Skipped to next declaration. components.css:247:9
Expected declaration but found @apply. Skipped to next declaration. components.css:251:9
Expected declaration but found @apply. Skipped to next declaration. components.css:255:9
Expected declaration but found @apply. Skipped to next declaration. components.css:259:9
Expected declaration but found @apply. Skipped to next declaration. components.css:263:9
Expected declaration but found @apply. Skipped to next declaration. components.css:267:9
Expected declaration but found @apply. Skipped to next declaration. components.css:272:9
Expected declaration but found @apply. Skipped to next declaration. components.css:276:9
Expected declaration but found @apply. Skipped to next declaration. components.css:280:9
Expected declaration but found @apply. Skipped to next declaration. components.css:284:9
Expected declaration but found @apply. Skipped to next declaration. components.css:288:9
Expected declaration but found @apply. Skipped to next declaration. components.css:293:9
Expected declaration but found @apply. Skipped to next declaration. components.css:297:9
Expected declaration but found @apply. Skipped to next declaration. components.css:301:9
Expected declaration but found @apply. Skipped to next declaration. components.css:305:9
Expected declaration but found @apply. Skipped to next declaration. components.css:309:9
Expected declaration but found @apply. Skipped to next declaration. components.css:314:9
Expected declaration but found @apply. Skipped to next declaration. components.css:318:9
Expected declaration but found @apply. Skipped to next declaration. components.css:322:9
Expected declaration but found @apply. Skipped to next declaration. components.css:326:9
Expected declaration but found @apply. Skipped to next declaration. components.css:330:9
Expected declaration but found @apply. Skipped to next declaration. components.css:334:9
Expected declaration but found @apply. Skipped to next declaration. components.css:339:9
Expected declaration but found @apply. Skipped to next declaration. components.css:344:9
Expected declaration but found @apply. Skipped to next declaration. components.css:348:9
Expected declaration but found @apply. Skipped to next declaration. components.css:352:9
Expected declaration but found @apply. Skipped to next declaration. components.css:357:9
Expected declaration but found @apply. Skipped to next declaration. components.css:361:9
Expected declaration but found @apply. Skipped to next declaration. components.css:365:9
Expected declaration but found @apply. Skipped to next declaration. components.css:370:9
Expected declaration but found @apply. Skipped to next declaration. components.css:374:9
Expected declaration but found @apply. Skipped to next declaration. components.css:379:9
Expected declaration but found @apply. Skipped to next declaration. components.css:383:9
Expected declaration but found @apply. Skipped to next declaration. components.css:387:9
Expected declaration but found @apply. Skipped to next declaration. components.css:391:9
Expected declaration but found @apply. Skipped to next declaration. components.css:396:9
Expected declaration but found @apply. Skipped to next declaration. components.css:400:9

View File

@@ -1,74 +0,0 @@
Alpine components script is loading... alpine-components.js:10:9
getEmbedInfo content.js:388:11
NO OEMBED content.js:456:11
Registering Alpine.js components... alpine-components.js:24:11
Alpine.js components registered successfully alpine-components.js:734:11
downloadable font: Glyph bbox was incorrect (glyph ids 2 3 5 8 9 10 11 12 14 17 19 21 22 32 34 35 39 40 43 44 45 46 47 49 51 52 54 56 57 58 60 61 62 63 64 65 67 68 69 71 74 75 76 77 79 86 89 91 96 98 99 100 102 103 109 110 111 113 116 117 118 124 127 128 129 130 132 133 134 137 138 140 142 143 145 146 147 155 156 159 160 171 172 173 177 192 201 202 203 204 207 208 209 210 225 231 233 234 235 238 239 243 244 246 252 253 254 256 259 261 262 268 269 278 279 280 281 285 287 288 295 296 302 303 304 305 307 308 309 313 315 322 324 353 355 356 357 360 362 367 370 371 376 390 396 397 398 400 403 404 407 408 415 416 417 418 423 424 425 427 428 432 433 434 435 436 439 451 452 455 461 467 470 471 482 483 485 489 491 496 499 500 505 514 529 532 541 542 543 547 549 551 553 554 555 556 557 559 579 580 581 582 584 591 592 593 594 595 596 597 600 601 608 609 614 615 622 624 649 658 659 662 664 673 679 680 681 682 684 687 688 689 692 693 694 695 696 698 699 700 702 708 710 711 712 714 716 719 723 724 727 728 729 731 732 733 739 750 751 754 755 756 758 759 761 762 763 766 770 776 778 781 792 795 798 800 802 803 807 808 810 813 818 822 823 826 834 837 854 860 861 862 863 866 867 871 872 874 875 881 882 883 886 892 894 895 897 898 900 901 902 907 910 913 915 917 920 927 936 937 943 945 946 947 949 950 951 954 955 956 958 961 962 964 965 966 968 969 970 974 976 978 980 981 982 985 986 991 992 998 1000 1001 1007 1008 1009 1010 1014 1016 1018 1020 1022 1023 1024 1027 1028 1033 1034 1035 1036 1037 1040 1041 1044 1045 1047 1048 1049 1053 1054 1055 1056 1057 1059 1061 1063 1064 1065 1072 1074 1075 1078 1079 1080 1081 1085 1086 1087 1088 1093 1095 1099 1100 1111 1112 1115 1116 1117 1120 1121 1122 1123 1124 1125) (font-family: "Font Awesome 6 Free" style:normal weight:900 stretch:100 src index:0) source: https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-solid-900.woff2
GET
https://d6d61dac-164d-45dd-929f-7dcdfd771b64-00-1bpe9dzxxnshv.worf.replit.dev/favicon.ico
[HTTP/1.1 404 Not Found 57ms]
Error in parsing value for -webkit-text-size-adjust. Declaration dropped. tailwind.css:162:31
Expected declaration but found @apply. Skipped to next declaration. components.css:137:9
Expected declaration but found @apply. Skipped to next declaration. components.css:141:9
Expected declaration but found @apply. Skipped to next declaration. components.css:145:9
Expected declaration but found @apply. Skipped to next declaration. components.css:149:9
Expected declaration but found @apply. Skipped to next declaration. components.css:153:9
Expected declaration but found @apply. Skipped to next declaration. components.css:157:9
Expected declaration but found @apply. Skipped to next declaration. components.css:161:9
Expected declaration but found @apply. Skipped to next declaration. components.css:165:9
Expected declaration but found @apply. Skipped to next declaration. components.css:169:9
Expected declaration but found @apply. Skipped to next declaration. components.css:173:9
Expected declaration but found @apply. Skipped to next declaration. components.css:178:9
Expected declaration but found @apply. Skipped to next declaration. components.css:182:9
Expected declaration but found @apply. Skipped to next declaration. components.css:186:9
Expected declaration but found @apply. Skipped to next declaration. components.css:190:9
Expected declaration but found @apply. Skipped to next declaration. components.css:194:9
Expected declaration but found @apply. Skipped to next declaration. components.css:198:9
Expected declaration but found @apply. Skipped to next declaration. components.css:203:9
Expected declaration but found @apply. Skipped to next declaration. components.css:208:9
Expected declaration but found @apply. Skipped to next declaration. components.css:212:9
Expected declaration but found @apply. Skipped to next declaration. components.css:216:9
Expected declaration but found @apply. Skipped to next declaration. components.css:220:9
Expected declaration but found @apply. Skipped to next declaration. components.css:225:9
Expected declaration but found @apply. Skipped to next declaration. components.css:229:9
Expected declaration but found @apply. Skipped to next declaration. components.css:234:9
Expected declaration but found @apply. Skipped to next declaration. components.css:238:9
Expected declaration but found @apply. Skipped to next declaration. components.css:242:9
Expected declaration but found @apply. Skipped to next declaration. components.css:247:9
Expected declaration but found @apply. Skipped to next declaration. components.css:251:9
Expected declaration but found @apply. Skipped to next declaration. components.css:255:9
Expected declaration but found @apply. Skipped to next declaration. components.css:259:9
Expected declaration but found @apply. Skipped to next declaration. components.css:263:9
Expected declaration but found @apply. Skipped to next declaration. components.css:267:9
Expected declaration but found @apply. Skipped to next declaration. components.css:272:9
Expected declaration but found @apply. Skipped to next declaration. components.css:276:9
Expected declaration but found @apply. Skipped to next declaration. components.css:280:9
Expected declaration but found @apply. Skipped to next declaration. components.css:284:9
Expected declaration but found @apply. Skipped to next declaration. components.css:288:9
Expected declaration but found @apply. Skipped to next declaration. components.css:293:9
Expected declaration but found @apply. Skipped to next declaration. components.css:297:9
Expected declaration but found @apply. Skipped to next declaration. components.css:301:9
Expected declaration but found @apply. Skipped to next declaration. components.css:305:9
Expected declaration but found @apply. Skipped to next declaration. components.css:309:9
Expected declaration but found @apply. Skipped to next declaration. components.css:314:9
Expected declaration but found @apply. Skipped to next declaration. components.css:318:9
Expected declaration but found @apply. Skipped to next declaration. components.css:322:9
Expected declaration but found @apply. Skipped to next declaration. components.css:326:9
Expected declaration but found @apply. Skipped to next declaration. components.css:330:9
Expected declaration but found @apply. Skipped to next declaration. components.css:334:9
Expected declaration but found @apply. Skipped to next declaration. components.css:339:9
Expected declaration but found @apply. Skipped to next declaration. components.css:344:9
Expected declaration but found @apply. Skipped to next declaration. components.css:348:9
Expected declaration but found @apply. Skipped to next declaration. components.css:352:9
Expected declaration but found @apply. Skipped to next declaration. components.css:357:9
Expected declaration but found @apply. Skipped to next declaration. components.css:361:9
Expected declaration but found @apply. Skipped to next declaration. components.css:365:9
Expected declaration but found @apply. Skipped to next declaration. components.css:370:9
Expected declaration but found @apply. Skipped to next declaration. components.css:374:9
Expected declaration but found @apply. Skipped to next declaration. components.css:379:9
Expected declaration but found @apply. Skipped to next declaration. components.css:383:9
Expected declaration but found @apply. Skipped to next declaration. components.css:387:9
Expected declaration but found @apply. Skipped to next declaration. components.css:391:9
Expected declaration but found @apply. Skipped to next declaration. components.css:396:9
Expected declaration but found @apply. Skipped to next declaration. components.css:400:9

View File

@@ -1,134 +0,0 @@
Environment:
Request Method: GET
Request URL: http://d6d61dac-164d-45dd-929f-7dcdfd771b64-00-1bpe9dzxxnshv.worf.replit.dev/
Django Version: 5.2.6
Python Version: 3.13.5
Installed Applications:
['django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
'django_cloudflareimages_toolkit',
'rest_framework',
'rest_framework.authtoken',
'rest_framework_simplejwt',
'rest_framework_simplejwt.token_blacklist',
'dj_rest_auth',
'dj_rest_auth.registration',
'drf_spectacular',
'corsheaders',
'pghistory',
'pgtrigger',
'allauth',
'allauth.account',
'allauth.socialaccount',
'allauth.socialaccount.providers.google',
'allauth.socialaccount.providers.discord',
'django_cleanup',
'django_filters',
'django_htmx',
'whitenoise',
'django_tailwind_cli',
'autocomplete',
'health_check',
'health_check.db',
'health_check.cache',
'health_check.storage',
'health_check.contrib.migrations',
'health_check.contrib.redis',
'django_celery_beat',
'django_celery_results',
'django_extensions',
'apps.core',
'apps.accounts',
'apps.parks',
'apps.rides',
'api',
'django_forwardemail',
'apps.moderation',
'nplusone.ext.django',
'widget_tweaks']
Installed Middleware:
['django.middleware.cache.UpdateCacheMiddleware',
'core.middleware.request_logging.RequestLoggingMiddleware',
'core.middleware.nextjs.APIResponseMiddleware',
'core.middleware.performance_middleware.QueryCountMiddleware',
'core.middleware.performance_middleware.PerformanceMiddleware',
'nplusone.ext.django.NPlusOneMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'apps.core.middleware.analytics.PgHistoryContextMiddleware',
'allauth.account.middleware.AccountMiddleware',
'django.middleware.cache.FetchFromCacheMiddleware',
'django_htmx.middleware.HtmxMiddleware']
Traceback (most recent call last):
File "/home/runner/workspace/backend/.venv/lib/python3.13/site-packages/django/core/handlers/exception.py", line 55, in inner
response = get_response(request)
^^^^^^^^^^^^^^^^^^^^^
File "/home/runner/workspace/backend/.venv/lib/python3.13/site-packages/django/core/handlers/base.py", line 197, in _get_response
response = wrapped_callback(request, *callback_args, **callback_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/runner/workspace/backend/.venv/lib/python3.13/site-packages/django/views/generic/base.py", line 105, in view
return self.dispatch(request, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/runner/workspace/backend/.venv/lib/python3.13/site-packages/django/views/generic/base.py", line 144, in dispatch
return handler(request, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/runner/workspace/backend/.venv/lib/python3.13/site-packages/django/views/generic/base.py", line 228, in get
context = self.get_context_data(**kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/runner/workspace/backend/thrillwiki/views.py", line 29, in get_context_data
"total_parks": Park.objects.count(),
^^^^^^^^^^^^^^^^^^^^
File "/home/runner/workspace/backend/.venv/lib/python3.13/site-packages/django/db/models/manager.py", line 87, in manager_method
return getattr(self.get_queryset(), name)(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/runner/workspace/backend/.venv/lib/python3.13/site-packages/django/db/models/query.py", line 604, in count
return self.query.get_count(using=self.db)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/runner/workspace/backend/.venv/lib/python3.13/site-packages/django/db/models/sql/query.py", line 644, in get_count
return obj.get_aggregation(using, {"__count": Count("*")})["__count"]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/runner/workspace/backend/.venv/lib/python3.13/site-packages/django/db/models/sql/query.py", line 626, in get_aggregation
result = compiler.execute_sql(SINGLE)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/runner/workspace/backend/.venv/lib/python3.13/site-packages/django/db/models/sql/compiler.py", line 1623, in execute_sql
cursor.execute(sql, params)
^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/runner/workspace/backend/.venv/lib/python3.13/site-packages/django/db/backends/utils.py", line 122, in execute
return super().execute(sql, params)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/runner/workspace/backend/.venv/lib/python3.13/site-packages/django/db/backends/utils.py", line 79, in execute
return self._execute_with_wrappers(
File "/home/runner/workspace/backend/.venv/lib/python3.13/site-packages/django/db/backends/utils.py", line 92, in _execute_with_wrappers
return executor(sql, params, many, context)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/runner/workspace/backend/.venv/lib/python3.13/site-packages/pghistory/runtime.py", line 96, in _inject_history_context
if _can_inject_variable(context["cursor"], sql):
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/runner/workspace/backend/.venv/lib/python3.13/site-packages/pghistory/runtime.py", line 77, in _can_inject_variable
and not _is_transaction_errored(cursor)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/runner/workspace/backend/.venv/lib/python3.13/site-packages/pghistory/runtime.py", line 51, in _is_transaction_errored
cursor.connection.get_transaction_status()
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Exception Type: AttributeError at /
Exception Value: 'sqlite3.Connection' object has no attribute 'get_transaction_status'

View File

@@ -1,92 +0,0 @@
Expected declaration but found @apply. Skipped to next declaration. alerts.css:3:11
Expected declaration but found @apply. Skipped to next declaration. alerts.css:8:11
Expected declaration but found @apply. Skipped to next declaration. alerts.css:12:11
Expected declaration but found @apply. Skipped to next declaration. alerts.css:16:11
Expected declaration but found @apply. Skipped to next declaration. alerts.css:20:11
Expected declaration but found @apply. Skipped to next declaration. components.css:137:9
Expected declaration but found @apply. Skipped to next declaration. components.css:141:9
Expected declaration but found @apply. Skipped to next declaration. components.css:145:9
Expected declaration but found @apply. Skipped to next declaration. components.css:149:9
Expected declaration but found @apply. Skipped to next declaration. components.css:153:9
Expected declaration but found @apply. Skipped to next declaration. components.css:157:9
Expected declaration but found @apply. Skipped to next declaration. components.css:161:9
Expected declaration but found @apply. Skipped to next declaration. components.css:165:9
Expected declaration but found @apply. Skipped to next declaration. components.css:169:9
Expected declaration but found @apply. Skipped to next declaration. components.css:173:9
Expected declaration but found @apply. Skipped to next declaration. components.css:178:9
Expected declaration but found @apply. Skipped to next declaration. components.css:182:9
Expected declaration but found @apply. Skipped to next declaration. components.css:186:9
Expected declaration but found @apply. Skipped to next declaration. components.css:190:9
Expected declaration but found @apply. Skipped to next declaration. components.css:194:9
Expected declaration but found @apply. Skipped to next declaration. components.css:198:9
Expected declaration but found @apply. Skipped to next declaration. components.css:203:9
Expected declaration but found @apply. Skipped to next declaration. components.css:208:9
Expected declaration but found @apply. Skipped to next declaration. components.css:212:9
Expected declaration but found @apply. Skipped to next declaration. components.css:216:9
Expected declaration but found @apply. Skipped to next declaration. components.css:220:9
Expected declaration but found @apply. Skipped to next declaration. components.css:225:9
Expected declaration but found @apply. Skipped to next declaration. components.css:229:9
Expected declaration but found @apply. Skipped to next declaration. components.css:234:9
Expected declaration but found @apply. Skipped to next declaration. components.css:238:9
Expected declaration but found @apply. Skipped to next declaration. components.css:244:9
Expected declaration but found @apply. Skipped to next declaration. components.css:249:9
Expected declaration but found @apply. Skipped to next declaration. components.css:253:9
Expected declaration but found @apply. Skipped to next declaration. components.css:257:9
Expected declaration but found @apply. Skipped to next declaration. components.css:261:9
Expected declaration but found @apply. Skipped to next declaration. components.css:265:9
Expected declaration but found @apply. Skipped to next declaration. components.css:269:9
Expected declaration but found @apply. Skipped to next declaration. components.css:274:9
Expected declaration but found @apply. Skipped to next declaration. components.css:278:9
Expected declaration but found @apply. Skipped to next declaration. components.css:282:9
Expected declaration but found @apply. Skipped to next declaration. components.css:286:9
Expected declaration but found @apply. Skipped to next declaration. components.css:290:9
Expected declaration but found @apply. Skipped to next declaration. components.css:295:9
Expected declaration but found @apply. Skipped to next declaration. components.css:299:9
Expected declaration but found @apply. Skipped to next declaration. components.css:303:9
Expected declaration but found @apply. Skipped to next declaration. components.css:307:9
Expected declaration but found @apply. Skipped to next declaration. components.css:311:9
Expected declaration but found @apply. Skipped to next declaration. components.css:316:9
Expected declaration but found @apply. Skipped to next declaration. components.css:320:9
Expected declaration but found @apply. Skipped to next declaration. components.css:324:9
Expected declaration but found @apply. Skipped to next declaration. components.css:328:9
Expected declaration but found @apply. Skipped to next declaration. components.css:332:9
Expected declaration but found @apply. Skipped to next declaration. components.css:336:9
Expected declaration but found @apply. Skipped to next declaration. components.css:341:9
Expected declaration but found @apply. Skipped to next declaration. components.css:346:9
Expected declaration but found @apply. Skipped to next declaration. components.css:350:9
Expected declaration but found @apply. Skipped to next declaration. components.css:354:9
Expected declaration but found @apply. Skipped to next declaration. components.css:359:9
Expected declaration but found @apply. Skipped to next declaration. components.css:363:9
Expected declaration but found @apply. Skipped to next declaration. components.css:367:9
Expected declaration but found @apply. Skipped to next declaration. components.css:372:9
Expected declaration but found @apply. Skipped to next declaration. components.css:376:9
Expected declaration but found @apply. Skipped to next declaration. components.css:381:9
Expected declaration but found @apply. Skipped to next declaration. components.css:385:9
Expected declaration but found @apply. Skipped to next declaration. components.css:389:9
Expected declaration but found @apply. Skipped to next declaration. components.css:393:9
Expected declaration but found @apply. Skipped to next declaration. components.css:398:9
Expected declaration but found @apply. Skipped to next declaration. components.css:402:9
Expected declaration but found @apply. Skipped to next declaration. components.css:406:9
Expected declaration but found @apply. Skipped to next declaration. components.css:411:9
Expected declaration but found @apply. Skipped to next declaration. components.css:416:9
Expected declaration but found @apply. Skipped to next declaration. components.css:420:9
Expected declaration but found @apply. Skipped to next declaration. components.css:425:9
Expected declaration but found @apply. Skipped to next declaration. components.css:430:9
Expected declaration but found @apply. Skipped to next declaration. components.css:435:9
Expected declaration but found @apply. Skipped to next declaration. components.css:439:9
Expected declaration but found @apply. Skipped to next declaration. components.css:443:9
Expected declaration but found @apply. Skipped to next declaration. components.css:517:11
Expected declaration but found @apply. Skipped to next declaration. components.css:521:11
Found invalid value for media feature. components.css:546:26
getEmbedInfo content.js:388:11
NO OEMBED content.js:456:11
Error in parsing value for -webkit-text-size-adjust. Declaration dropped. tailwind.css:162:31
Layout was forced before the page was fully loaded. If stylesheets are not yet loaded this may cause a flash of unstyled content. node.js:409:1
Alpine components script is loading... alpine-components.js:10:9
Registering Alpine.js components... alpine-components.js:24:11
Alpine.js components registered successfully alpine-components.js:734:11
GET
https://d6d61dac-164d-45dd-929f-7dcdfd771b64-00-1bpe9dzxxnshv.worf.replit.dev/favicon.ico
[HTTP/1.1 404 Not Found 56ms]
downloadable font: Glyph bbox was incorrect (glyph ids 2 3 5 8 9 10 11 12 14 17 19 21 22 32 34 35 39 40 43 44 45 46 47 49 51 52 54 56 57 58 60 61 62 63 64 65 67 68 69 71 74 75 76 77 79 86 89 91 96 98 99 100 102 103 109 110 111 113 116 117 118 124 127 128 129 130 132 133 134 137 138 140 142 143 145 146 147 155 156 159 160 171 172 173 177 192 201 202 203 204 207 208 209 210 225 231 233 234 235 238 239 243 244 246 252 253 254 256 259 261 262 268 269 278 279 280 281 285 287 288 295 296 302 303 304 305 307 308 309 313 315 322 324 353 355 356 357 360 362 367 370 371 376 390 396 397 398 400 403 404 407 408 415 416 417 418 423 424 425 427 428 432 433 434 435 436 439 451 452 455 461 467 470 471 482 483 485 489 491 496 499 500 505 514 529 532 541 542 543 547 549 551 553 554 555 556 557 559 579 580 581 582 584 591 592 593 594 595 596 597 600 601 608 609 614 615 622 624 649 658 659 662 664 673 679 680 681 682 684 687 688 689 692 693 694 695 696 698 699 700 702 708 710 711 712 714 716 719 723 724 727 728 729 731 732 733 739 750 751 754 755 756 758 759 761 762 763 766 770 776 778 781 792 795 798 800 802 803 807 808 810 813 818 822 823 826 834 837 854 860 861 862 863 866 867 871 872 874 875 881 882 883 886 892 894 895 897 898 900 901 902 907 910 913 915 917 920 927 936 937 943 945 946 947 949 950 951 954 955 956 958 961 962 964 965 966 968 969 970 974 976 978 980 981 982 985 986 991 992 998 1000 1001 1007 1008 1009 1010 1014 1016 1018 1020 1022 1023 1024 1027 1028 1033 1034 1035 1036 1037 1040 1041 1044 1045 1047 1048 1049 1053 1054 1055 1056 1057 1059 1061 1063 1064 1065 1072 1074 1075 1078 1079 1080 1081 1085 1086 1087 1088 1093 1095 1099 1100 1111 1112 1115 1116 1117 1120 1121 1122 1123 1124 1125) (font-family: "Font Awesome 6 Free" style:normal weight:900 stretch:100 src index:0) source: https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-solid-900.woff2

View File

@@ -1,92 +0,0 @@
Expected declaration but found @apply. Skipped to next declaration. alerts.css:3:11
Expected declaration but found @apply. Skipped to next declaration. alerts.css:8:11
Expected declaration but found @apply. Skipped to next declaration. alerts.css:12:11
Expected declaration but found @apply. Skipped to next declaration. alerts.css:16:11
Expected declaration but found @apply. Skipped to next declaration. alerts.css:20:11
Expected declaration but found @apply. Skipped to next declaration. components.css:137:9
Expected declaration but found @apply. Skipped to next declaration. components.css:141:9
Expected declaration but found @apply. Skipped to next declaration. components.css:145:9
Expected declaration but found @apply. Skipped to next declaration. components.css:149:9
Expected declaration but found @apply. Skipped to next declaration. components.css:153:9
Expected declaration but found @apply. Skipped to next declaration. components.css:157:9
Expected declaration but found @apply. Skipped to next declaration. components.css:161:9
Expected declaration but found @apply. Skipped to next declaration. components.css:165:9
Expected declaration but found @apply. Skipped to next declaration. components.css:169:9
Expected declaration but found @apply. Skipped to next declaration. components.css:173:9
Expected declaration but found @apply. Skipped to next declaration. components.css:178:9
Expected declaration but found @apply. Skipped to next declaration. components.css:182:9
Expected declaration but found @apply. Skipped to next declaration. components.css:186:9
Expected declaration but found @apply. Skipped to next declaration. components.css:190:9
Expected declaration but found @apply. Skipped to next declaration. components.css:194:9
Expected declaration but found @apply. Skipped to next declaration. components.css:198:9
Expected declaration but found @apply. Skipped to next declaration. components.css:203:9
Expected declaration but found @apply. Skipped to next declaration. components.css:208:9
Expected declaration but found @apply. Skipped to next declaration. components.css:212:9
Expected declaration but found @apply. Skipped to next declaration. components.css:216:9
Expected declaration but found @apply. Skipped to next declaration. components.css:220:9
Expected declaration but found @apply. Skipped to next declaration. components.css:225:9
Expected declaration but found @apply. Skipped to next declaration. components.css:229:9
Expected declaration but found @apply. Skipped to next declaration. components.css:234:9
Expected declaration but found @apply. Skipped to next declaration. components.css:238:9
Expected declaration but found @apply. Skipped to next declaration. components.css:244:9
Expected declaration but found @apply. Skipped to next declaration. components.css:249:9
Expected declaration but found @apply. Skipped to next declaration. components.css:253:9
Expected declaration but found @apply. Skipped to next declaration. components.css:257:9
Expected declaration but found @apply. Skipped to next declaration. components.css:261:9
Expected declaration but found @apply. Skipped to next declaration. components.css:265:9
Expected declaration but found @apply. Skipped to next declaration. components.css:269:9
Expected declaration but found @apply. Skipped to next declaration. components.css:274:9
Expected declaration but found @apply. Skipped to next declaration. components.css:278:9
Expected declaration but found @apply. Skipped to next declaration. components.css:282:9
Expected declaration but found @apply. Skipped to next declaration. components.css:286:9
Expected declaration but found @apply. Skipped to next declaration. components.css:290:9
Expected declaration but found @apply. Skipped to next declaration. components.css:295:9
Expected declaration but found @apply. Skipped to next declaration. components.css:299:9
Expected declaration but found @apply. Skipped to next declaration. components.css:303:9
Expected declaration but found @apply. Skipped to next declaration. components.css:307:9
Expected declaration but found @apply. Skipped to next declaration. components.css:311:9
Expected declaration but found @apply. Skipped to next declaration. components.css:316:9
Expected declaration but found @apply. Skipped to next declaration. components.css:320:9
Expected declaration but found @apply. Skipped to next declaration. components.css:324:9
Expected declaration but found @apply. Skipped to next declaration. components.css:328:9
Expected declaration but found @apply. Skipped to next declaration. components.css:332:9
Expected declaration but found @apply. Skipped to next declaration. components.css:336:9
Expected declaration but found @apply. Skipped to next declaration. components.css:341:9
Expected declaration but found @apply. Skipped to next declaration. components.css:346:9
Expected declaration but found @apply. Skipped to next declaration. components.css:350:9
Expected declaration but found @apply. Skipped to next declaration. components.css:354:9
Expected declaration but found @apply. Skipped to next declaration. components.css:359:9
Expected declaration but found @apply. Skipped to next declaration. components.css:363:9
Expected declaration but found @apply. Skipped to next declaration. components.css:367:9
Expected declaration but found @apply. Skipped to next declaration. components.css:372:9
Expected declaration but found @apply. Skipped to next declaration. components.css:376:9
Expected declaration but found @apply. Skipped to next declaration. components.css:381:9
Expected declaration but found @apply. Skipped to next declaration. components.css:385:9
Expected declaration but found @apply. Skipped to next declaration. components.css:389:9
Expected declaration but found @apply. Skipped to next declaration. components.css:393:9
Expected declaration but found @apply. Skipped to next declaration. components.css:398:9
Expected declaration but found @apply. Skipped to next declaration. components.css:402:9
Expected declaration but found @apply. Skipped to next declaration. components.css:406:9
Expected declaration but found @apply. Skipped to next declaration. components.css:411:9
Expected declaration but found @apply. Skipped to next declaration. components.css:416:9
Expected declaration but found @apply. Skipped to next declaration. components.css:420:9
Expected declaration but found @apply. Skipped to next declaration. components.css:425:9
Expected declaration but found @apply. Skipped to next declaration. components.css:430:9
Expected declaration but found @apply. Skipped to next declaration. components.css:435:9
Expected declaration but found @apply. Skipped to next declaration. components.css:439:9
Expected declaration but found @apply. Skipped to next declaration. components.css:443:9
Expected declaration but found @apply. Skipped to next declaration. components.css:517:11
Expected declaration but found @apply. Skipped to next declaration. components.css:521:11
Found invalid value for media feature. components.css:546:26
getEmbedInfo content.js:388:11
NO OEMBED content.js:456:11
Error in parsing value for -webkit-text-size-adjust. Declaration dropped. tailwind.css:162:31
Layout was forced before the page was fully loaded. If stylesheets are not yet loaded this may cause a flash of unstyled content. node.js:409:1
Alpine components script is loading... alpine-components.js:10:9
Registering Alpine.js components... alpine-components.js:24:11
Alpine.js components registered successfully alpine-components.js:734:11
GET
https://d6d61dac-164d-45dd-929f-7dcdfd771b64-00-1bpe9dzxxnshv.worf.replit.dev/favicon.ico
[HTTP/1.1 404 Not Found 56ms]
downloadable font: Glyph bbox was incorrect (glyph ids 2 3 5 8 9 10 11 12 14 17 19 21 22 32 34 35 39 40 43 44 45 46 47 49 51 52 54 56 57 58 60 61 62 63 64 65 67 68 69 71 74 75 76 77 79 86 89 91 96 98 99 100 102 103 109 110 111 113 116 117 118 124 127 128 129 130 132 133 134 137 138 140 142 143 145 146 147 155 156 159 160 171 172 173 177 192 201 202 203 204 207 208 209 210 225 231 233 234 235 238 239 243 244 246 252 253 254 256 259 261 262 268 269 278 279 280 281 285 287 288 295 296 302 303 304 305 307 308 309 313 315 322 324 353 355 356 357 360 362 367 370 371 376 390 396 397 398 400 403 404 407 408 415 416 417 418 423 424 425 427 428 432 433 434 435 436 439 451 452 455 461 467 470 471 482 483 485 489 491 496 499 500 505 514 529 532 541 542 543 547 549 551 553 554 555 556 557 559 579 580 581 582 584 591 592 593 594 595 596 597 600 601 608 609 614 615 622 624 649 658 659 662 664 673 679 680 681 682 684 687 688 689 692 693 694 695 696 698 699 700 702 708 710 711 712 714 716 719 723 724 727 728 729 731 732 733 739 750 751 754 755 756 758 759 761 762 763 766 770 776 778 781 792 795 798 800 802 803 807 808 810 813 818 822 823 826 834 837 854 860 861 862 863 866 867 871 872 874 875 881 882 883 886 892 894 895 897 898 900 901 902 907 910 913 915 917 920 927 936 937 943 945 946 947 949 950 951 954 955 956 958 961 962 964 965 966 968 969 970 974 976 978 980 981 982 985 986 991 992 998 1000 1001 1007 1008 1009 1010 1014 1016 1018 1020 1022 1023 1024 1027 1028 1033 1034 1035 1036 1037 1040 1041 1044 1045 1047 1048 1049 1053 1054 1055 1056 1057 1059 1061 1063 1064 1065 1072 1074 1075 1078 1079 1080 1081 1085 1086 1087 1088 1093 1095 1099 1100 1111 1112 1115 1116 1117 1120 1121 1122 1123 1124 1125) (font-family: "Font Awesome 6 Free" style:normal weight:900 stretch:100 src index:0) source: https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-solid-900.woff2

View File

@@ -1,12 +0,0 @@
Found invalid value for media feature. components.css:476:26
Error in parsing value for -webkit-text-size-adjust. Declaration dropped. tailwind.css:162:31
Alpine components script is loading... alpine-components.js:10:9
Registering Alpine.js components... alpine-components.js:24:11
Alpine.js components registered successfully alpine-components.js:734:11
getEmbedInfo content.js:388:11
NO OEMBED content.js:456:11
downloadable font: Glyph bbox was incorrect (glyph ids 2 3 5 8 9 10 11 12 14 17 19 21 22 32 34 35 39 40 43 44 45 46 47 49 51 52 54 56 57 58 60 61 62 63 64 65 67 68 69 71 74 75 76 77 79 86 89 91 96 98 99 100 102 103 109 110 111 113 116 117 118 124 127 128 129 130 132 133 134 137 138 140 142 143 145 146 147 155 156 159 160 171 172 173 177 192 201 202 203 204 207 208 209 210 225 231 233 234 235 238 239 243 244 246 252 253 254 256 259 261 262 268 269 278 279 280 281 285 287 288 295 296 302 303 304 305 307 308 309 313 315 322 324 353 355 356 357 360 362 367 370 371 376 390 396 397 398 400 403 404 407 408 415 416 417 418 423 424 425 427 428 432 433 434 435 436 439 451 452 455 461 467 470 471 482 483 485 489 491 496 499 500 505 514 529 532 541 542 543 547 549 551 553 554 555 556 557 559 579 580 581 582 584 591 592 593 594 595 596 597 600 601 608 609 614 615 622 624 649 658 659 662 664 673 679 680 681 682 684 687 688 689 692 693 694 695 696 698 699 700 702 708 710 711 712 714 716 719 723 724 727 728 729 731 732 733 739 750 751 754 755 756 758 759 761 762 763 766 770 776 778 781 792 795 798 800 802 803 807 808 810 813 818 822 823 826 834 837 854 860 861 862 863 866 867 871 872 874 875 881 882 883 886 892 894 895 897 898 900 901 902 907 910 913 915 917 920 927 936 937 943 945 946 947 949 950 951 954 955 956 958 961 962 964 965 966 968 969 970 974 976 978 980 981 982 985 986 991 992 998 1000 1001 1007 1008 1009 1010 1014 1016 1018 1020 1022 1023 1024 1027 1028 1033 1034 1035 1036 1037 1040 1041 1044 1045 1047 1048 1049 1053 1054 1055 1056 1057 1059 1061 1063 1064 1065 1072 1074 1075 1078 1079 1080 1081 1085 1086 1087 1088 1093 1095 1099 1100 1111 1112 1115 1116 1117 1120 1121 1122 1123 1124 1125) (font-family: "Font Awesome 6 Free" style:normal weight:900 stretch:100 src index:0) source: https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-solid-900.woff2
GET
https://d6d61dac-164d-45dd-929f-7dcdfd771b64-00-1bpe9dzxxnshv.worf.replit.dev/favicon.ico
[HTTP/1.1 404 Not Found 58ms]

View File

@@ -1,12 +0,0 @@
Found invalid value for media feature. components.css:476:26
Error in parsing value for -webkit-text-size-adjust. Declaration dropped. tailwind.css:162:31
Alpine components script is loading... alpine-components.js:10:9
Registering Alpine.js components... alpine-components.js:24:11
Alpine.js components registered successfully alpine-components.js:734:11
getEmbedInfo content.js:388:11
NO OEMBED content.js:456:11
downloadable font: Glyph bbox was incorrect (glyph ids 2 3 5 8 9 10 11 12 14 17 19 21 22 32 34 35 39 40 43 44 45 46 47 49 51 52 54 56 57 58 60 61 62 63 64 65 67 68 69 71 74 75 76 77 79 86 89 91 96 98 99 100 102 103 109 110 111 113 116 117 118 124 127 128 129 130 132 133 134 137 138 140 142 143 145 146 147 155 156 159 160 171 172 173 177 192 201 202 203 204 207 208 209 210 225 231 233 234 235 238 239 243 244 246 252 253 254 256 259 261 262 268 269 278 279 280 281 285 287 288 295 296 302 303 304 305 307 308 309 313 315 322 324 353 355 356 357 360 362 367 370 371 376 390 396 397 398 400 403 404 407 408 415 416 417 418 423 424 425 427 428 432 433 434 435 436 439 451 452 455 461 467 470 471 482 483 485 489 491 496 499 500 505 514 529 532 541 542 543 547 549 551 553 554 555 556 557 559 579 580 581 582 584 591 592 593 594 595 596 597 600 601 608 609 614 615 622 624 649 658 659 662 664 673 679 680 681 682 684 687 688 689 692 693 694 695 696 698 699 700 702 708 710 711 712 714 716 719 723 724 727 728 729 731 732 733 739 750 751 754 755 756 758 759 761 762 763 766 770 776 778 781 792 795 798 800 802 803 807 808 810 813 818 822 823 826 834 837 854 860 861 862 863 866 867 871 872 874 875 881 882 883 886 892 894 895 897 898 900 901 902 907 910 913 915 917 920 927 936 937 943 945 946 947 949 950 951 954 955 956 958 961 962 964 965 966 968 969 970 974 976 978 980 981 982 985 986 991 992 998 1000 1001 1007 1008 1009 1010 1014 1016 1018 1020 1022 1023 1024 1027 1028 1033 1034 1035 1036 1037 1040 1041 1044 1045 1047 1048 1049 1053 1054 1055 1056 1057 1059 1061 1063 1064 1065 1072 1074 1075 1078 1079 1080 1081 1085 1086 1087 1088 1093 1095 1099 1100 1111 1112 1115 1116 1117 1120 1121 1122 1123 1124 1125) (font-family: "Font Awesome 6 Free" style:normal weight:900 stretch:100 src index:0) source: https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-solid-900.woff2
GET
https://d6d61dac-164d-45dd-929f-7dcdfd771b64-00-1bpe9dzxxnshv.worf.replit.dev/favicon.ico
[HTTP/1.1 404 Not Found 58ms]

View File

@@ -1,116 +0,0 @@
Traceback (most recent call last):
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/contrib/staticfiles/handlers.py", line 80, in __call__
return self.application(environ, start_response)
~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/core/handlers/wsgi.py", line 124, in __call__
response = self.get_response(request)
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/core/handlers/base.py", line 140, in get_response
response = self._middleware_chain(request)
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/core/handlers/exception.py", line 57, in inner
response = response_for_exception(request, exc)
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/core/handlers/exception.py", line 141, in response_for_exception
response = handle_uncaught_exception(
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/core/handlers/exception.py", line 182, in handle_uncaught_exception
return debug.technical_500_response(request, *exc_info)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django_extensions/management/technical_response.py", line 41, in null_technical_500_response
raise exc_value.with_traceback(tb)
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/core/handlers/exception.py", line 55, in inner
response = get_response(request)
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/core/handlers/base.py", line 220, in _get_response
response = response.render()
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/response.py", line 114, in render
self.content = self.rendered_content
^^^^^^^^^^^^^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/response.py", line 92, in rendered_content
return template.render(context, self._request)
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/backends/django.py", line 107, in render
return self.template.render(context)
~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 171, in render
return self._render(context)
~~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 163, in _render
return self.nodelist.render(context)
~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 1016, in render
return SafeString("".join([node.render_annotated(context) for node in self]))
~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 977, in render_annotated
return self.render(context)
~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/loader_tags.py", line 159, in render
return compiled_parent._render(context)
~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 163, in _render
return self.nodelist.render(context)
~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 1016, in render
return SafeString("".join([node.render_annotated(context) for node in self]))
~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 977, in render_annotated
return self.render(context)
~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/loader_tags.py", line 65, in render
result = block.nodelist.render(context)
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 1016, in render
return SafeString("".join([node.render_annotated(context) for node in self]))
~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 977, in render_annotated
return self.render(context)
~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/defaulttags.py", line 243, in render
nodelist.append(node.render_annotated(context))
~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 977, in render_annotated
return self.render(context)
~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django_cotton/templatetags/_component.py", line 86, in render
output = template.render(context)
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 173, in render
return self._render(context)
~~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 163, in _render
return self.nodelist.render(context)
~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 1016, in render
return SafeString("".join([node.render_annotated(context) for node in self]))
~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 977, in render_annotated
return self.render(context)
~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django_cotton/templatetags/_vars.py", line 52, in render
output = self.nodelist.render(context)
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 1016, in render
return SafeString("".join([node.render_annotated(context) for node in self]))
~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 977, in render_annotated
return self.render(context)
~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/defaulttags.py", line 327, in render
return nodelist.render(context)
~~~~~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 1016, in render
return SafeString("".join([node.render_annotated(context) for node in self]))
~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 977, in render_annotated
return self.render(context)
~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/defaulttags.py", line 327, in render
return nodelist.render(context)
~~~~~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 1016, in render
return SafeString("".join([node.render_annotated(context) for node in self]))
~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 977, in render_annotated
return self.render(context)
~~~~~~~~~~~^^^^^^^^^
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/defaulttags.py", line 480, in render
url = reverse(view_name, args=args, kwargs=kwargs, current_app=current_app)
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/urls/base.py", line 98, in reverse
resolved_url = resolver._reverse_with_prefix(view, prefix, *args, **kwargs)
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/urls/resolvers.py", line 831, in _reverse_with_prefix
raise NoReverseMatch(msg)
django.urls.exceptions.NoReverseMatch: Reverse for 'park_detail' with arguments '('',)' not found. 1 pattern(s) tried: ['parks/(?P<slug>[-a-zA-Z0-9_]+)/\\Z']

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 713 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 B

48
backend/.env.example Normal file
View File

@@ -0,0 +1,48 @@
# Django Configuration
SECRET_KEY=your-secret-key-here
DEBUG=True
DJANGO_SETTINGS_MODULE=config.django.local
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/thrillwiki
# Redis
REDIS_URL=redis://localhost:6379
# Email Configuration (Optional)
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=your-email@gmail.com
EMAIL_HOST_PASSWORD=your-app-password
# ForwardEmail API Configuration
FORWARD_EMAIL_BASE_URL=https://api.forwardemail.net
FORWARD_EMAIL_API_KEY=your-forwardemail-api-key-here
FORWARD_EMAIL_DOMAIN=your-domain.com
# Media and Static Files
MEDIA_URL=/media/
STATIC_URL=/static/
# Security
ALLOWED_HOSTS=localhost,127.0.0.1
# API Configuration
CORS_ALLOWED_ORIGINS=http://localhost:3000
# Feature Flags
ENABLE_DEBUG_TOOLBAR=True
ENABLE_SILK_PROFILER=False
# Frontend Configuration
FRONTEND_DOMAIN=https://thrillwiki.com
# Cloudflare Images Configuration
CLOUDFLARE_IMAGES_ACCOUNT_ID=your-cloudflare-account-id
CLOUDFLARE_IMAGES_API_TOKEN=your-cloudflare-api-token
CLOUDFLARE_IMAGES_ACCOUNT_HASH=your-cloudflare-account-hash
CLOUDFLARE_IMAGES_WEBHOOK_SECRET=your-webhook-secret
# Road Trip Service Configuration
ROADTRIP_USER_AGENT=ThrillWiki/1.0 (https://thrillwiki.com)

229
backend/README.md Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,438 @@
# Generated by Django 5.2.5 on 2025-08-24 19:11
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0002_remove_toplistevent_pgh_context_and_more"),
("pghistory", "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,
),
),
]

View File

@@ -0,0 +1,219 @@
# Generated by Django 5.2.5 on 2025-08-29 14:55
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"accounts",
"0003_emailverificationevent_passwordresetevent_userevent_and_more",
),
("pghistory", "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",
),
),
),
]

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,509 @@
# Generated by Django 5.2.5 on 2025-08-30 20:55
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0008_remove_first_last_name_fields"),
("contenttypes", "0002_remove_content_type_name"),
("django_cloudflareimages_toolkit", "0001_initial"),
("pghistory", "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",
),
),
),
]

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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
View 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

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