feat: Enhance Park Detail Endpoint with Media URL Service Integration

- Updated ParkDetailOutputSerializer to utilize MediaURLService for generating Cloudflare URLs and friendly URLs for park photos.
- Added support for multiple lookup methods (ID and slug) in the park detail endpoint.
- Improved documentation for the park detail endpoint, including request properties and response structure.
- Created MediaURLService for generating SEO-friendly URLs and handling Cloudflare image URLs.
- Comprehensive updates to frontend documentation to reflect new endpoint capabilities and usage examples.
- Added detailed park detail endpoint documentation, including request and response structures, field descriptions, and usage examples.
This commit is contained in:
pacnpal
2025-08-31 16:45:47 -04:00
parent 91906e0d57
commit 0fd6dc2560
12 changed files with 1530 additions and 380 deletions

View File

@@ -1,23 +1,77 @@
## Mandatory Development Rules ## Brief overview
Critical thinking rules for frontend design decisions. No excuses for poor design choices that ignore user vision.
### API Organization ## 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. - **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. Avoid creating separate top-level API endpoints - nest them under existing domains instead. - **NO TOP-LEVEL ENDPOINTS**: URLs must be nested under top-level domains
- **MANDATORY TRAILING SLASHES**: All API endpoints MUST include trailing forward slashes unless ending with query parameters
### Data Model Rules - Validate all endpoint URLs against the mandatory trailing slash rule
- **RIDE TYPES vs RIDE MODELS**: These are separate concepts for ALL ride categories: - **RIDE TYPES vs RIDE MODELS**: These are separate concepts for ALL ride categories:
- **Ride Types**: How rides operate (e.g., "inverted", "trackless", "spinning", "log flume", "monorail") - **Ride Types**: How rides operate (e.g., "inverted", "trackless", "spinning", "log flume", "monorail")
- **Ride Models**: Specific manufacturer products (e.g., "B&M Dive Coaster", "Vekoma Boomerang") - **Ride Models**: Specific manufacturer products (e.g., "B&M Dive Coaster", "Vekoma Boomerang")
- Individual rides reference BOTH the model (what product) and type (how it operates) - Individual rides reference BOTH the model (what product) and type (how it operates)
- Ride types must be available for ALL ride categories, not just roller coasters - Ride types must be available for ALL ride categories, not just roller coasters
### Development Commands ## Development Commands and Code Quality
- **Django Server**: Always use `uv run manage.py runserver_plus` instead of `python manage.py runserver` - **Django Server**: Always use `uv run manage.py runserver_plus` instead of `python manage.py runserver`
- **Django Migrations**: Always use `uv run manage.py makemigrations` and `uv run manage.py migrate` instead of `python manage.py` - **Django Migrations**: Always use `uv run manage.py makemigrations` and `uv run manage.py migrate` instead of `python manage.py`
- **Package Management**: Always use `uv add <package>` instead of `pip install <package>` - **Package Management**: Always use `uv add <package>` instead of `pip install <package>`
- **Django Management**: Always use `uv run manage.py <command>` instead of `python manage.py <command>` - **Django Management**: Always use `uv run manage.py <command>` instead of `python manage.py <command>`
- Break down methods with high cognitive complexity (>15) into smaller, focused helper methods
- Extract logical operations into separate methods with descriptive names
- Use single responsibility principle - each method should have one clear purpose
- Prefer composition over deeply nested conditional logic
- Always handle None values explicitly to avoid type errors
- Use proper type annotations, including union types (e.g., `Polygon | None`)
- Structure API views with clear separation between parameter handling, business logic, and response building
- When addressing SonarQube or linting warnings, focus on structural improvements rather than quick fixes
### ThrillWiki Project Rules ## ThrillWiki Project Rules
- **Domain Structure**: Parks contain rides, rides have models, companies have multiple roles (manufacturer/operator/designer) - **Domain Structure**: Parks contain rides, rides have models, companies have multiple roles (manufacturer/operator/designer)
- **Media Integration**: Use CloudflareImagesField for all photo uploads with variants and transformations - **Media Integration**: Use CloudflareImagesField for all photo uploads with variants and transformations
- **Tracking**: All models use pghistory for change tracking and TrackedModel base class - **Tracking**: All models use pghistory for change tracking and TrackedModel base class
@@ -30,38 +84,8 @@
- **Search Integration**: Text search, filtering, autocomplete endpoints, pagination - **Search Integration**: Text search, filtering, autocomplete endpoints, pagination
- **Statistics**: Cached stats endpoints with automatic invalidation via Django signals - **Statistics**: Cached stats endpoints with automatic invalidation via Django signals
### CRITICAL DOCUMENTATION RULE ## CRITICAL RULES
- CRITICAL: After every change, it is MANDATORY to update docs/frontend.md with ALL documentation on how to use the updated API endpoints and features. Your edits to that file must be comprehensive and include all relevant details. If the file does not exist, you must create it and assume it is for a NextJS frontend. - **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.
- CRITICAL: It is MANDATORY to include any types that need to be added to the frontend in docs/types-api.ts for NextJS as the file would appear in `src/types/api.ts` in the NextJS project exactly. Again, create it if it does not exist. Make sure it is in sync with docs/api.ts. Full type safety.
- IT IS MANDATORY that api calls include a trailing forward slash. See example below. The forward slash may only be omitted if the end of the endpoint is a query parameter such as ``/companies/${query ? `?${query}` : ''}` or `/map/locations/${createQuery(params)}`.
Example:
```
async updateAvatar(formData: FormData): Promise<User> {
return makeRequest('/auth/user/avatar/', {
method: 'POST',
body: formData,
headers: {}, // Let browser set Content-Type for FormData
});
},`
```
- The types-api.ts file should import the types file as such `@/types/api` and not from any other location.
- CRITICAL: 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` in the NextJS project exactly. Again, create it if it does not exist. Make sure it is in sync with docs/types.ts. Full type safety.
### CRITICAL DATA RULE
- **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. - **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.
### CRITICAL DOMAIN SEPARATION RULE - **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.
- **OPERATORS AND PROPERTY_OWNERS ARE FOR PARKS ONLY**: Company roles OPERATOR and PROPERTY_OWNER are EXCLUSIVELY for parks domain. They should NEVER be used in rides URLs or ride-related contexts. Only MANUFACTURER and DESIGNER roles are for rides domain.
- **Correct URL patterns**:
- Parks: `/parks/{park_slug}/` and `/parks/` showing a global list of parks with all possible fields.
- Rides: `/parks/{park_slug}/rides/{ride_slug}/` and `/rides/` showing a global list of rides with all possible fields.
- Parks Companies: `/parks/operators/{operator_slug}/` and `/parks/owners/{owner_slug}/` and `/parks/operators/` and `/parks/owners/` showing a global list of park operators or owners respectively with filter options based on all fields.
- Rides Companies: `/rides/manufacturers/{manufacturer_slug}/` and `/rides/designers/{designer_slug}/` and `/rides/manufacturers/` and `/rides/designers/` showing a global list of ride manufacturers or designers respectively with filter options based on all fields.
- **NEVER mix these domains** - this is a fundamental and DANGEROUS business rule violation.
### CRITICAL PHOTO MANAGEMENT RULE
- **Use CloudflareImagesField**: All photo uploads must use CloudflareImagesField with variants and transformations.
- **Photo Types**: Clearly define and use photo types (e.g., banner, card) for all images.
- **Attribution Fields**: Include attribution fields for all photos, specifying the source and copyright information.
- **Primary Photo Logic**: Implement logic to determine the primary photo for each model, ensuring consistency across the application.

View File

@@ -1,37 +0,0 @@
## Brief overview
Guidelines for managing code complexity and maintaining clean, maintainable code in Django projects. These rules focus on reducing cognitive complexity, improving code organization, and following best practices for refactoring complex methods.
## Cognitive complexity management
- Always break down methods with high cognitive complexity (>15) into smaller, focused helper methods
- Extract logical operations into separate methods with descriptive names
- Use single responsibility principle - each method should have one clear purpose
- Prefer composition over deeply nested conditional logic
- When refactoring complex methods, maintain original functionality while improving structure
## Method extraction patterns
- Create helper methods for parameter parsing and validation
- Separate data retrieval logic from serialization logic
- Extract cache operations into dedicated methods
- Use descriptive method names that clearly indicate their purpose (e.g., `_serialize_park_data`, `_get_parks_data`)
- Keep helper methods focused and avoid creating new complexity within them
## Error handling and type safety
- Always handle None values explicitly to avoid type errors
- Use proper type annotations, including union types (e.g., `Polygon | None`)
- Implement fallback mechanisms for operations that may not be available in all environments
- Validate input parameters before processing to prevent runtime errors
- Use `getattr()` with defaults when accessing potentially missing attributes
## Django API view organization
- Structure API views with clear separation between parameter handling, business logic, and response building
- Use helper methods to reduce the main method complexity while preserving readability
- Maintain consistent error response formats across all endpoints
- Implement proper caching strategies with appropriate fallbacks
- Follow DRF patterns for serialization and response construction
## Refactoring approach
- When addressing SonarQube or linting warnings, focus on structural improvements rather than quick fixes
- Preserve all original functionality during refactoring
- Test edge cases and error conditions after complexity reduction
- Maintain API contracts and response formats
- Document complex business logic within helper methods when necessary

View File

@@ -1,40 +0,0 @@
## Brief overview
Guidelines for creating comprehensive frontend integration prompts after any backend changes that affect the frontend API. These rules ensure frontend developers have all necessary information to implement changes without requiring code samples or extensive back-and-forth communication.
## Mandatory prompt creation
- MUST create a frontend integration prompt after every backend change that affects API endpoints, parameters, or data structures
- Prompt files should be saved in the docs/ directory with descriptive names ending in "-llm-prompt.md"
- Never assume frontend developers have access to backend code or can infer implementation details
## Prompt content requirements
- Include complete API endpoint information with all parameters and their types
- Document all mandatory API rules (trailing slashes, HTTP methods, authentication requirements)
- Provide comprehensive parameter lists organized by logical categories
- Explain data relationships and hierarchies that affect frontend implementation
- Include error handling requirements and expected response formats
## Information organization
- Start with project context and high-level objectives
- Group related parameters and concepts into logical sections
- Use clear headings and bullet points for easy scanning
- Avoid code samples but include detailed technical specifications
- Document both required and optional parameters with clear descriptions
## Frontend-specific considerations
- Include TypeScript integration requirements and type safety guidelines
- Document state management patterns and URL synchronization needs
- Provide user experience recommendations for complex filtering interfaces
- Explain performance optimization strategies relevant to the changes
- Include testing considerations and validation requirements
## Backend compatibility notes
- Always confirm which features are fully supported vs planned
- Document any database schema changes that affect API behavior
- Explain data source patterns (real database queries vs static lists)
- Include information about caching, pagination, and response metadata
## Documentation maintenance
- Update existing frontend documentation files when creating new prompts
- Ensure consistency between lib-api.ts, types-api.ts, and frontend.md files
- Reference related documentation files and their current status
- Include version information or timestamps when relevant for tracking changes

View File

@@ -1,32 +0,0 @@
## Brief overview
This rule file establishes strict compliance enforcement protocols for mandatory development standards. These guidelines are project-specific and stem from critical rule violations that compromised system integrity. All rules marked as "MANDATORY" in project documentation must be followed without exception.
## Rule compliance verification
- Always read and review ALL .clinerules files before making any code changes
- Verify compliance with mandatory formatting requirements before committing
- Double-check work against explicitly stated project standards
- Never assume exceptions to rules marked as "MANDATORY"
## API documentation standards
- All API endpoints MUST include trailing forward slashes unless ending with query parameters
- Follow the exact format specified in .clinerules for API endpoint documentation
- Validate all endpoint URLs against the mandatory trailing slash rule
- Ensure consistency across all API documentation files
## Quality assurance protocols
- Perform systematic review of all changes against project rules
- Validate that modifications comply with architectural standards
- Check for systematic patterns that might indicate rule violations
- Implement self-review processes before submitting any work
## Accountability measures
- Take full responsibility for rule violations without excuses
- Acknowledge when mandatory standards have been compromised
- Accept consequences for systematic non-compliance
- Demonstrate commitment to following established project standards
## Documentation integrity
- Maintain accuracy and compliance in all technical documentation
- Ensure API documentation matches backend URL routing expectations
- Preserve system architecture integrity through compliant documentation
- Follow project-specific formatting requirements exactly as specified

View File

@@ -12,6 +12,7 @@ Supports all 24 filtering parameters from frontend API documentation.
""" """
from typing import Any from typing import Any
from django.db import models
from django.db.models import Q, Count, Avg from django.db.models import Q, Count, Avg
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
@@ -401,20 +402,22 @@ class ParkListCreateAPIView(APIView):
# --- Park retrieve / update / delete --------------------------------------- # --- Park retrieve / update / delete ---------------------------------------
@extend_schema( @extend_schema(
summary="Retrieve, update or delete a park", summary="Retrieve, update or delete a park by ID or slug",
description="Retrieve full park details including location, photos, areas, rides, and company information. Supports both ID and slug-based lookup with historical slug support.",
responses={ responses={
200: ( 200: (
"ParkDetailOutputSerializer()" "ParkDetailOutputSerializer()"
if SERIALIZERS_AVAILABLE if SERIALIZERS_AVAILABLE
else OpenApiTypes.OBJECT else OpenApiTypes.OBJECT
) ),
404: OpenApiTypes.OBJECT,
}, },
tags=["Parks"], tags=["Parks"],
) )
class ParkDetailAPIView(APIView): class ParkDetailAPIView(APIView):
permission_classes = [permissions.AllowAny] permission_classes = [permissions.AllowAny]
def _get_park_or_404(self, pk: int) -> Any: def _get_park_or_404(self, identifier: str) -> Any:
if not MODELS_AVAILABLE: if not MODELS_AVAILABLE:
raise NotFound( raise NotFound(
( (
@@ -423,13 +426,77 @@ class ParkDetailAPIView(APIView):
"to enable detail endpoints." "to enable detail endpoints."
) )
) )
# Try to parse as integer ID first
try: try:
# type: ignore pk = int(identifier)
return Park.objects.select_related("operator", "property_owner").get(pk=pk) try:
except Park.DoesNotExist: # type: ignore return Park.objects.select_related(
"operator", "property_owner", "location"
).prefetch_related(
"areas", "rides", "photos"
).get(pk=pk)
except Park.DoesNotExist:
raise NotFound("Park not found")
except ValueError:
# Not an integer, try slug lookup
try:
park, is_historical = Park.get_by_slug(identifier)
# Ensure we have the full related data
return Park.objects.select_related(
"operator", "property_owner", "location"
).prefetch_related(
"areas", "rides", "photos"
).get(pk=park.pk)
except Park.DoesNotExist:
raise NotFound("Park not found") raise NotFound("Park not found")
def get(self, request: Request, pk: int) -> Response: @extend_schema(
summary="Get park full details",
description="""
Retrieve comprehensive park details including:
**Core Information:**
- Basic park details (name, slug, description, status)
- Opening/closing dates and operating season
- Size in acres and website URL
- Statistics (average rating, ride count, coaster count)
**Location Data:**
- Full address with coordinates
- City, state, country information
- Formatted address string
**Company Information:**
- Operating company details
- Property owner information (if different)
**Media:**
- All approved photos with Cloudflare variants
- Primary photo designation
- Banner and card image settings
**Related Content:**
- Park areas/themed sections
- Associated rides (summary)
**Lookup Methods:**
- By ID: `/api/v1/parks/123/`
- By current slug: `/api/v1/parks/cedar-point/`
- By historical slug: `/api/v1/parks/old-cedar-point-name/`
**No Query Parameters Required** - This endpoint returns full details by default.
""",
responses={
200: (
"ParkDetailOutputSerializer()"
if SERIALIZERS_AVAILABLE
else OpenApiTypes.OBJECT
),
404: OpenApiTypes.OBJECT,
},
)
def get(self, request: Request, pk: str) -> Response:
park = self._get_park_or_404(pk) park = self._get_park_or_404(pk)
if SERIALIZERS_AVAILABLE: if SERIALIZERS_AVAILABLE:
serializer = ParkDetailOutputSerializer(park, context={"request": request}) serializer = ParkDetailOutputSerializer(park, context={"request": request})
@@ -451,7 +518,7 @@ class ParkDetailAPIView(APIView):
} }
) )
def patch(self, request: Request, pk: int) -> Response: def patch(self, request: Request, pk: str) -> Response:
park = self._get_park_or_404(pk) park = self._get_park_or_404(pk)
if not SERIALIZERS_AVAILABLE: if not SERIALIZERS_AVAILABLE:
return Response( return Response(
@@ -478,11 +545,11 @@ class ParkDetailAPIView(APIView):
serializer = ParkDetailOutputSerializer(park, context={"request": request}) serializer = ParkDetailOutputSerializer(park, context={"request": request})
return Response(serializer.data) return Response(serializer.data)
def put(self, request: Request, pk: int) -> Response: def put(self, request: Request, pk: str) -> Response:
# Full replace - reuse patch behavior for simplicity # Full replace - reuse patch behavior for simplicity
return self.patch(request, pk) return self.patch(request, pk)
def delete(self, request: Request, pk: int) -> Response: def delete(self, request: Request, pk: str) -> Response:
if not MODELS_AVAILABLE: if not MODELS_AVAILABLE:
return Response( return Response(
{ {
@@ -508,9 +575,9 @@ class FilterOptionsAPIView(APIView):
permission_classes = [permissions.AllowAny] permission_classes = [permissions.AllowAny]
def get(self, request: Request) -> Response: def get(self, request: Request) -> Response:
"""Return comprehensive filter options matching frontend API documentation.""" """Return comprehensive filter options with all possible park model fields and attributes."""
if not MODELS_AVAILABLE: if not MODELS_AVAILABLE:
# Fallback comprehensive options # Fallback comprehensive options with all possible fields
return Response({ return Response({
"park_types": [ "park_types": [
{"value": "THEME_PARK", "label": "Theme Park"}, {"value": "THEME_PARK", "label": "Theme Park"},
@@ -518,6 +585,21 @@ class FilterOptionsAPIView(APIView):
{"value": "WATER_PARK", "label": "Water Park"}, {"value": "WATER_PARK", "label": "Water Park"},
{"value": "FAMILY_ENTERTAINMENT_CENTER", {"value": "FAMILY_ENTERTAINMENT_CENTER",
"label": "Family Entertainment Center"}, "label": "Family Entertainment Center"},
{"value": "CARNIVAL", "label": "Carnival"},
{"value": "FAIR", "label": "Fair"},
{"value": "PIER", "label": "Pier"},
{"value": "BOARDWALK", "label": "Boardwalk"},
{"value": "SAFARI_PARK", "label": "Safari Park"},
{"value": "ZOO", "label": "Zoo"},
{"value": "OTHER", "label": "Other"},
],
"statuses": [
{"value": "OPERATING", "label": "Operating"},
{"value": "CLOSED_TEMP", "label": "Temporarily Closed"},
{"value": "CLOSED_PERM", "label": "Permanently Closed"},
{"value": "UNDER_CONSTRUCTION", "label": "Under Construction"},
{"value": "DEMOLISHED", "label": "Demolished"},
{"value": "RELOCATED", "label": "Relocated"},
], ],
"continents": [ "continents": [
"North America", "North America",
@@ -546,6 +628,21 @@ class FilterOptionsAPIView(APIView):
"Texas", "Texas",
"New York" "New York"
], ],
"cities": [
"Orlando",
"Los Angeles",
"Cedar Point",
"Sandusky"
],
"operators": [],
"property_owners": [],
"ranges": {
"rating": {"min": 1, "max": 10, "step": 0.1, "unit": "stars"},
"ride_count": {"min": 0, "max": 100, "step": 1, "unit": "rides"},
"coaster_count": {"min": 0, "max": 50, "step": 1, "unit": "coasters"},
"size_acres": {"min": 0, "max": 10000, "step": 1, "unit": "acres"},
"opening_year": {"min": 1800, "max": 2030, "step": 1, "unit": "year"},
},
"ordering_options": [ "ordering_options": [
{"value": "name", "label": "Name (A-Z)"}, {"value": "name", "label": "Name (A-Z)"},
{"value": "-name", "label": "Name (Z-A)"}, {"value": "-name", "label": "Name (Z-A)"},
@@ -553,18 +650,36 @@ class FilterOptionsAPIView(APIView):
{"value": "-opening_date", "label": "Opening Date (Newest First)"}, {"value": "-opening_date", "label": "Opening Date (Newest First)"},
{"value": "ride_count", "label": "Ride Count (Low to High)"}, {"value": "ride_count", "label": "Ride Count (Low to High)"},
{"value": "-ride_count", "label": "Ride Count (High to Low)"}, {"value": "-ride_count", "label": "Ride Count (High to Low)"},
{"value": "coaster_count", "label": "Coaster Count (Low to High)"},
{"value": "-coaster_count", "label": "Coaster Count (High to Low)"},
{"value": "average_rating", "label": "Rating (Low to High)"}, {"value": "average_rating", "label": "Rating (Low to High)"},
{"value": "-average_rating", "label": "Rating (High to Low)"}, {"value": "-average_rating", "label": "Rating (High to Low)"},
{"value": "roller_coaster_count", {"value": "size_acres", "label": "Size (Small to Large)"},
"label": "Coaster Count (Low to High)"}, {"value": "-size_acres", "label": "Size (Large to Small)"},
{"value": "-roller_coaster_count", {"value": "created_at",
"label": "Coaster Count (High to Low)"}, "label": "Added to Database (Oldest First)"},
{"value": "-created_at",
"label": "Added to Database (Newest First)"},
{"value": "updated_at", "label": "Last Updated (Oldest First)"},
{"value": "-updated_at", "label": "Last Updated (Newest First)"},
], ],
}) })
# Try to get dynamic options from database # Try to get dynamic options from database
try: try:
# Get continents from database (now available field) # Get all park types from model choices
park_types = [
{"value": choice[0], "label": choice[1]}
for choice in Park.PARK_TYPE_CHOICES
]
# Get all statuses from model choices
statuses = [
{"value": choice[0], "label": choice[1]}
for choice in Park.STATUS_CHOICES
]
# Get location data from database
continents = list(Park.objects.exclude( continents = list(Park.objects.exclude(
location__continent__isnull=True location__continent__isnull=True
).exclude( ).exclude(
@@ -595,17 +710,78 @@ class FilterOptionsAPIView(APIView):
location__state__exact='' location__state__exact=''
).values_list('location__state', flat=True).distinct().order_by('location__state')) ).values_list('location__state', flat=True).distinct().order_by('location__state'))
# Get park types from model choices (now available field) cities = list(Park.objects.exclude(
park_types = [ location__city__isnull=True
{"value": choice[0], "label": choice[1]} ).exclude(
for choice in Park.PARK_TYPE_CHOICES location__city__exact=''
] ).values_list('location__city', flat=True).distinct().order_by('location__city'))
# Get operators and property owners
operators = list(Company.objects.filter(
roles__contains=['OPERATOR']
).values('id', 'name', 'slug').order_by('name'))
property_owners = list(Company.objects.filter(
roles__contains=['PROPERTY_OWNER']
).values('id', 'name', 'slug').order_by('name'))
# Calculate ranges from actual data
park_stats = Park.objects.aggregate(
min_rating=models.Min('average_rating'),
max_rating=models.Max('average_rating'),
min_ride_count=models.Min('ride_count'),
max_ride_count=models.Max('ride_count'),
min_coaster_count=models.Min('coaster_count'),
max_coaster_count=models.Max('coaster_count'),
min_size=models.Min('size_acres'),
max_size=models.Max('size_acres'),
min_year=models.Min('opening_date__year'),
max_year=models.Max('opening_date__year'),
)
ranges = {
"rating": {
"min": float(park_stats['min_rating'] or 1),
"max": float(park_stats['max_rating'] or 10),
"step": 0.1,
"unit": "stars"
},
"ride_count": {
"min": park_stats['min_ride_count'] or 0,
"max": park_stats['max_ride_count'] or 100,
"step": 1,
"unit": "rides"
},
"coaster_count": {
"min": park_stats['min_coaster_count'] or 0,
"max": park_stats['max_coaster_count'] or 50,
"step": 1,
"unit": "coasters"
},
"size_acres": {
"min": float(park_stats['min_size'] or 0),
"max": float(park_stats['max_size'] or 10000),
"step": 1,
"unit": "acres"
},
"opening_year": {
"min": park_stats['min_year'] or 1800,
"max": park_stats['max_year'] or 2030,
"step": 1,
"unit": "year"
},
}
return Response({ return Response({
"park_types": park_types, "park_types": park_types,
"statuses": statuses,
"continents": continents, "continents": continents,
"countries": countries, "countries": countries,
"states": states, "states": states,
"cities": cities,
"operators": operators,
"property_owners": property_owners,
"ranges": ranges,
"ordering_options": [ "ordering_options": [
{"value": "name", "label": "Name (A-Z)"}, {"value": "name", "label": "Name (A-Z)"},
{"value": "-name", "label": "Name (Z-A)"}, {"value": "-name", "label": "Name (Z-A)"},
@@ -613,12 +789,18 @@ class FilterOptionsAPIView(APIView):
{"value": "-opening_date", "label": "Opening Date (Newest First)"}, {"value": "-opening_date", "label": "Opening Date (Newest First)"},
{"value": "ride_count", "label": "Ride Count (Low to High)"}, {"value": "ride_count", "label": "Ride Count (Low to High)"},
{"value": "-ride_count", "label": "Ride Count (High to Low)"}, {"value": "-ride_count", "label": "Ride Count (High to Low)"},
{"value": "coaster_count", "label": "Coaster Count (Low to High)"},
{"value": "-coaster_count", "label": "Coaster Count (High to Low)"},
{"value": "average_rating", "label": "Rating (Low to High)"}, {"value": "average_rating", "label": "Rating (Low to High)"},
{"value": "-average_rating", "label": "Rating (High to Low)"}, {"value": "-average_rating", "label": "Rating (High to Low)"},
{"value": "roller_coaster_count", {"value": "size_acres", "label": "Size (Small to Large)"},
"label": "Coaster Count (Low to High)"}, {"value": "-size_acres", "label": "Size (Large to Small)"},
{"value": "-roller_coaster_count", {"value": "created_at",
"label": "Coaster Count (High to Low)"}, "label": "Added to Database (Oldest First)"},
{"value": "-created_at",
"label": "Added to Database (Newest First)"},
{"value": "updated_at", "label": "Last Updated (Oldest First)"},
{"value": "-updated_at", "label": "Last Updated (Newest First)"},
], ],
}) })
@@ -629,6 +811,23 @@ class FilterOptionsAPIView(APIView):
{"value": "THEME_PARK", "label": "Theme Park"}, {"value": "THEME_PARK", "label": "Theme Park"},
{"value": "AMUSEMENT_PARK", "label": "Amusement Park"}, {"value": "AMUSEMENT_PARK", "label": "Amusement Park"},
{"value": "WATER_PARK", "label": "Water Park"}, {"value": "WATER_PARK", "label": "Water Park"},
{"value": "FAMILY_ENTERTAINMENT_CENTER",
"label": "Family Entertainment Center"},
{"value": "CARNIVAL", "label": "Carnival"},
{"value": "FAIR", "label": "Fair"},
{"value": "PIER", "label": "Pier"},
{"value": "BOARDWALK", "label": "Boardwalk"},
{"value": "SAFARI_PARK", "label": "Safari Park"},
{"value": "ZOO", "label": "Zoo"},
{"value": "OTHER", "label": "Other"},
],
"statuses": [
{"value": "OPERATING", "label": "Operating"},
{"value": "CLOSED_TEMP", "label": "Temporarily Closed"},
{"value": "CLOSED_PERM", "label": "Permanently Closed"},
{"value": "UNDER_CONSTRUCTION", "label": "Under Construction"},
{"value": "DEMOLISHED", "label": "Demolished"},
{"value": "RELOCATED", "label": "Relocated"},
], ],
"continents": [ "continents": [
"North America", "North America",
@@ -652,6 +851,20 @@ class FilterOptionsAPIView(APIView):
"Ohio", "Ohio",
"Pennsylvania" "Pennsylvania"
], ],
"cities": [
"Orlando",
"Los Angeles",
"Cedar Point"
],
"operators": [],
"property_owners": [],
"ranges": {
"rating": {"min": 1, "max": 10, "step": 0.1, "unit": "stars"},
"ride_count": {"min": 0, "max": 100, "step": 1, "unit": "rides"},
"coaster_count": {"min": 0, "max": 50, "step": 1, "unit": "coasters"},
"size_acres": {"min": 0, "max": 10000, "step": 1, "unit": "acres"},
"opening_year": {"min": 1800, "max": 2030, "step": 1, "unit": "year"},
},
"ordering_options": [ "ordering_options": [
{"value": "name", "label": "Name (A-Z)"}, {"value": "name", "label": "Name (A-Z)"},
{"value": "-name", "label": "Name (Z-A)"}, {"value": "-name", "label": "Name (Z-A)"},
@@ -659,6 +872,10 @@ class FilterOptionsAPIView(APIView):
{"value": "-opening_date", "label": "Opening Date (Newest First)"}, {"value": "-opening_date", "label": "Opening Date (Newest First)"},
{"value": "ride_count", "label": "Ride Count (Low to High)"}, {"value": "ride_count", "label": "Ride Count (Low to High)"},
{"value": "-ride_count", "label": "Ride Count (High to Low)"}, {"value": "-ride_count", "label": "Ride Count (High to Low)"},
{"value": "coaster_count", "label": "Coaster Count (Low to High)"},
{"value": "-coaster_count", "label": "Coaster Count (High to Low)"},
{"value": "average_rating", "label": "Rating (Low to High)"},
{"value": "-average_rating", "label": "Rating (High to Low)"},
], ],
}) })

View File

@@ -41,8 +41,8 @@ urlpatterns = [
ParkSearchSuggestionsAPIView.as_view(), ParkSearchSuggestionsAPIView.as_view(),
name="park-search-suggestions", name="park-search-suggestions",
), ),
# Detail and action endpoints # Detail and action endpoints - supports both ID and slug
path("<int:pk>/", ParkDetailAPIView.as_view(), name="park-detail"), path("<str:pk>/", ParkDetailAPIView.as_view(), name="park-detail"),
# Park image settings endpoint # Park image settings endpoint
path( path(
"<int:pk>/image-settings/", "<int:pk>/image-settings/",

View File

@@ -680,7 +680,7 @@ class RideDetailAPIView(APIView):
# --- Filter options --------------------------------------------------------- # --- Filter options ---------------------------------------------------------
@extend_schema( @extend_schema(
summary="Get comprehensive filter options for rides", summary="Get comprehensive filter options for rides",
description="Returns all available filter options for rides including categories, statuses, roller coaster types, track materials, launch types, and ordering options.", description="Returns all available filter options for rides with complete read-only access to all possible ride model fields and attributes, including dynamic data from database.",
responses={200: OpenApiTypes.OBJECT}, responses={200: OpenApiTypes.OBJECT},
tags=["Rides"], tags=["Rides"],
) )
@@ -688,132 +688,96 @@ class FilterOptionsAPIView(APIView):
permission_classes = [permissions.AllowAny] permission_classes = [permissions.AllowAny]
def get(self, request: Request) -> Response: def get(self, request: Request) -> Response:
"""Return comprehensive filter options used by the frontend.""" """Return comprehensive filter options with all possible ride model fields and attributes."""
# Try to use ModelChoices if available if not MODELS_AVAILABLE:
if HAVE_MODELCHOICES and ModelChoices is not None: # Comprehensive fallback options with all possible fields
try: return Response({
data = {
"categories": ModelChoices.get_ride_category_choices(),
"statuses": ModelChoices.get_ride_status_choices(),
"post_closing_statuses": ModelChoices.get_ride_post_closing_choices(),
"roller_coaster_types": ModelChoices.get_coaster_type_choices(),
"track_materials": ModelChoices.get_coaster_track_choices(),
"launch_types": ModelChoices.get_launch_choices(),
"ordering_options": [
{"value": "name", "label": "Name (A-Z)"},
{"value": "-name", "label": "Name (Z-A)"},
{
"value": "opening_date",
"label": "Opening Date (Oldest First)",
},
{
"value": "-opening_date",
"label": "Opening Date (Newest First)",
},
{"value": "average_rating", "label": "Rating (Lowest First)"},
{"value": "-average_rating", "label": "Rating (Highest First)"},
{
"value": "capacity_per_hour",
"label": "Capacity (Lowest First)",
},
{
"value": "-capacity_per_hour",
"label": "Capacity (Highest First)",
},
{"value": "height_ft", "label": "Height (Shortest First)"},
{"value": "-height_ft", "label": "Height (Tallest First)"},
{"value": "speed_mph", "label": "Speed (Slowest First)"},
{"value": "-speed_mph", "label": "Speed (Fastest First)"},
{"value": "created_at", "label": "Date Added (Oldest First)"},
{"value": "-created_at", "label": "Date Added (Newest First)"},
],
"filter_ranges": {
"rating": {"min": 1, "max": 10, "step": 0.1},
"height_requirement": {
"min": 30,
"max": 90,
"step": 1,
"unit": "inches",
},
"capacity": {
"min": 0,
"max": 5000,
"step": 50,
"unit": "riders/hour",
},
"height_ft": {"min": 0, "max": 500, "step": 5, "unit": "feet"},
"speed_mph": {"min": 0, "max": 150, "step": 5, "unit": "mph"},
"inversions": {
"min": 0,
"max": 20,
"step": 1,
"unit": "inversions",
},
"opening_year": {
"min": 1800,
"max": 2030,
"step": 1,
"unit": "year",
},
},
"boolean_filters": [
{
"key": "has_inversions",
"label": "Has Inversions",
"description": "Filter roller coasters with or without inversions",
},
],
}
return Response(data)
except Exception:
# fallthrough to fallback
pass
# Comprehensive fallback options
return Response(
{
"categories": [ "categories": [
("RC", "Roller Coaster"), {"value": "RC", "label": "Roller Coaster"},
("DR", "Dark Ride"), {"value": "DR", "label": "Dark Ride"},
("FR", "Flat Ride"), {"value": "FR", "label": "Flat Ride"},
("WR", "Water Ride"), {"value": "WR", "label": "Water Ride"},
("TR", "Transport"), {"value": "TR", "label": "Transport"},
("OT", "Other"), {"value": "OT", "label": "Other"},
], ],
"statuses": [ "statuses": [
("OPERATING", "Operating"), {"value": "OPERATING", "label": "Operating"},
("CLOSED_TEMP", "Temporarily Closed"), {"value": "CLOSED_TEMP", "label": "Temporarily Closed"},
("SBNO", "Standing But Not Operating"), {"value": "SBNO", "label": "Standing But Not Operating"},
("CLOSING", "Closing"), {"value": "CLOSING", "label": "Closing"},
("CLOSED_PERM", "Permanently Closed"), {"value": "CLOSED_PERM", "label": "Permanently Closed"},
("UNDER_CONSTRUCTION", "Under Construction"), {"value": "UNDER_CONSTRUCTION", "label": "Under Construction"},
("DEMOLISHED", "Demolished"), {"value": "DEMOLISHED", "label": "Demolished"},
("RELOCATED", "Relocated"), {"value": "RELOCATED", "label": "Relocated"},
],
"post_closing_statuses": [
{"value": "SBNO", "label": "Standing But Not Operating"},
{"value": "CLOSED_PERM", "label": "Permanently Closed"},
], ],
"roller_coaster_types": [ "roller_coaster_types": [
("SITDOWN", "Sit Down"), {"value": "SITDOWN", "label": "Sit Down"},
("INVERTED", "Inverted"), {"value": "INVERTED", "label": "Inverted"},
("FLYING", "Flying"), {"value": "FLYING", "label": "Flying"},
("STANDUP", "Stand Up"), {"value": "STANDUP", "label": "Stand Up"},
("WING", "Wing"), {"value": "WING", "label": "Wing"},
("DIVE", "Dive"), {"value": "DIVE", "label": "Dive"},
("FAMILY", "Family"), {"value": "FAMILY", "label": "Family"},
("WILD_MOUSE", "Wild Mouse"), {"value": "WILD_MOUSE", "label": "Wild Mouse"},
("SPINNING", "Spinning"), {"value": "SPINNING", "label": "Spinning"},
("FOURTH_DIMENSION", "4th Dimension"), {"value": "FOURTH_DIMENSION", "label": "4th Dimension"},
("OTHER", "Other"), {"value": "OTHER", "label": "Other"},
], ],
"track_materials": [ "track_materials": [
("STEEL", "Steel"), {"value": "STEEL", "label": "Steel"},
("WOOD", "Wood"), {"value": "WOOD", "label": "Wood"},
("HYBRID", "Hybrid"), {"value": "HYBRID", "label": "Hybrid"},
], ],
"launch_types": [ "launch_types": [
("CHAIN", "Chain Lift"), {"value": "CHAIN", "label": "Chain Lift"},
("LSM", "LSM Launch"), {"value": "LSM", "label": "LSM Launch"},
("HYDRAULIC", "Hydraulic Launch"), {"value": "HYDRAULIC", "label": "Hydraulic Launch"},
("GRAVITY", "Gravity"), {"value": "GRAVITY", "label": "Gravity"},
("OTHER", "Other"), {"value": "OTHER", "label": "Other"},
],
"ride_model_target_markets": [
{"value": "FAMILY", "label": "Family"},
{"value": "THRILL", "label": "Thrill"},
{"value": "EXTREME", "label": "Extreme"},
{"value": "KIDDIE", "label": "Kiddie"},
{"value": "ALL_AGES", "label": "All Ages"},
],
"parks": [],
"park_areas": [],
"manufacturers": [],
"designers": [],
"ride_models": [],
"ranges": {
"rating": {"min": 1, "max": 10, "step": 0.1, "unit": "stars"},
"height_requirement": {"min": 30, "max": 90, "step": 1, "unit": "inches"},
"capacity": {"min": 0, "max": 5000, "step": 50, "unit": "riders/hour"},
"ride_duration": {"min": 0, "max": 600, "step": 10, "unit": "seconds"},
"height_ft": {"min": 0, "max": 500, "step": 5, "unit": "feet"},
"length_ft": {"min": 0, "max": 10000, "step": 100, "unit": "feet"},
"speed_mph": {"min": 0, "max": 150, "step": 5, "unit": "mph"},
"inversions": {"min": 0, "max": 20, "step": 1, "unit": "inversions"},
"ride_time": {"min": 0, "max": 600, "step": 10, "unit": "seconds"},
"max_drop_height_ft": {"min": 0, "max": 500, "step": 10, "unit": "feet"},
"trains_count": {"min": 1, "max": 10, "step": 1, "unit": "trains"},
"cars_per_train": {"min": 1, "max": 20, "step": 1, "unit": "cars"},
"seats_per_car": {"min": 1, "max": 8, "step": 1, "unit": "seats"},
"opening_year": {"min": 1800, "max": 2030, "step": 1, "unit": "year"},
},
"boolean_filters": [
{"key": "has_inversions", "label": "Has Inversions",
"description": "Filter roller coasters with or without inversions"},
{"key": "has_coordinates", "label": "Has Location Coordinates",
"description": "Filter rides with GPS coordinates"},
{"key": "has_ride_model", "label": "Has Ride Model",
"description": "Filter rides with specified ride model"},
{"key": "has_manufacturer", "label": "Has Manufacturer",
"description": "Filter rides with specified manufacturer"},
{"key": "has_designer", "label": "Has Designer",
"description": "Filter rides with specified designer"},
], ],
"ordering_options": [ "ordering_options": [
{"value": "name", "label": "Name (A-Z)"}, {"value": "name", "label": "Name (A-Z)"},
@@ -823,55 +787,399 @@ class FilterOptionsAPIView(APIView):
{"value": "average_rating", "label": "Rating (Lowest First)"}, {"value": "average_rating", "label": "Rating (Lowest First)"},
{"value": "-average_rating", "label": "Rating (Highest First)"}, {"value": "-average_rating", "label": "Rating (Highest First)"},
{"value": "capacity_per_hour", "label": "Capacity (Lowest First)"}, {"value": "capacity_per_hour", "label": "Capacity (Lowest First)"},
{ {"value": "-capacity_per_hour",
"value": "-capacity_per_hour", "label": "Capacity (Highest First)"},
"label": "Capacity (Highest First)", {"value": "ride_duration_seconds",
}, "label": "Duration (Shortest First)"},
{"value": "-ride_duration_seconds",
"label": "Duration (Longest First)"},
{"value": "height_ft", "label": "Height (Shortest First)"}, {"value": "height_ft", "label": "Height (Shortest First)"},
{"value": "-height_ft", "label": "Height (Tallest First)"}, {"value": "-height_ft", "label": "Height (Tallest First)"},
{"value": "length_ft", "label": "Length (Shortest First)"},
{"value": "-length_ft", "label": "Length (Longest First)"},
{"value": "speed_mph", "label": "Speed (Slowest First)"}, {"value": "speed_mph", "label": "Speed (Slowest First)"},
{"value": "-speed_mph", "label": "Speed (Fastest First)"}, {"value": "-speed_mph", "label": "Speed (Fastest First)"},
{"value": "inversions", "label": "Inversions (Fewest First)"},
{"value": "-inversions", "label": "Inversions (Most First)"},
{"value": "created_at", "label": "Date Added (Oldest First)"}, {"value": "created_at", "label": "Date Added (Oldest First)"},
{"value": "-created_at", "label": "Date Added (Newest First)"}, {"value": "-created_at", "label": "Date Added (Newest First)"},
{"value": "updated_at", "label": "Last Updated (Oldest First)"},
{"value": "-updated_at", "label": "Last Updated (Newest First)"},
], ],
"filter_ranges": { })
"rating": {"min": 1, "max": 10, "step": 0.1},
# Try to get dynamic options from database
try:
# Get all ride categories from model choices
categories = [
{"value": choice[0], "label": choice[1]}
for choice in Ride.CATEGORY_CHOICES if choice[0] # Skip empty choice
]
# Get all ride statuses from model choices
statuses = [
{"value": choice[0], "label": choice[1]}
for choice in Ride.STATUS_CHOICES if choice[0] # Skip empty choice
]
# Get post-closing statuses from model choices
post_closing_statuses = [
{"value": choice[0], "label": choice[1]}
for choice in Ride.POST_CLOSING_STATUS_CHOICES
]
# Get roller coaster types from model choices
from apps.rides.models.rides import RollerCoasterStats
roller_coaster_types = [
{"value": choice[0], "label": choice[1]}
for choice in RollerCoasterStats.COASTER_TYPE_CHOICES
]
# Get track materials from model choices
track_materials = [
{"value": choice[0], "label": choice[1]}
for choice in RollerCoasterStats.TRACK_MATERIAL_CHOICES
]
# Get launch types from model choices
launch_types = [
{"value": choice[0], "label": choice[1]}
for choice in RollerCoasterStats.LAUNCH_CHOICES
]
# Get ride model target markets from model choices
ride_model_target_markets = [
{"value": choice[0], "label": choice[1]}
for choice in RideModel._meta.get_field('target_market').choices
]
# Get parks data from database
parks = list(Ride.objects.exclude(
park__isnull=True
).select_related('park').values(
'park__id', 'park__name', 'park__slug'
).distinct().order_by('park__name'))
# Get park areas data from database
park_areas = list(Ride.objects.exclude(
park_area__isnull=True
).select_related('park_area').values(
'park_area__id', 'park_area__name', 'park_area__slug'
).distinct().order_by('park_area__name'))
# Get manufacturers (companies with MANUFACTURER role)
manufacturers = list(Company.objects.filter(
roles__contains=['MANUFACTURER']
).values('id', 'name', 'slug').order_by('name'))
# Get designers (companies with DESIGNER role)
designers = list(Company.objects.filter(
roles__contains=['DESIGNER']
).values('id', 'name', 'slug').order_by('name'))
# Get ride models data from database
ride_models = list(RideModel.objects.select_related(
'manufacturer'
).values(
'id', 'name', 'slug', 'manufacturer__name', 'manufacturer__slug', 'category'
).order_by('manufacturer__name', 'name'))
# Calculate ranges from actual data
ride_stats = Ride.objects.aggregate(
min_rating=models.Min('average_rating'),
max_rating=models.Max('average_rating'),
min_height_req=models.Min('min_height_in'),
max_height_req=models.Max('max_height_in'),
min_capacity=models.Min('capacity_per_hour'),
max_capacity=models.Max('capacity_per_hour'),
min_duration=models.Min('ride_duration_seconds'),
max_duration=models.Max('ride_duration_seconds'),
min_year=models.Min('opening_date__year'),
max_year=models.Max('opening_date__year'),
)
# Calculate roller coaster specific ranges
coaster_stats = RollerCoasterStats.objects.aggregate(
min_height_ft=models.Min('height_ft'),
max_height_ft=models.Max('height_ft'),
min_length_ft=models.Min('length_ft'),
max_length_ft=models.Max('length_ft'),
min_speed_mph=models.Min('speed_mph'),
max_speed_mph=models.Max('speed_mph'),
min_inversions=models.Min('inversions'),
max_inversions=models.Max('inversions'),
min_ride_time=models.Min('ride_time_seconds'),
max_ride_time=models.Max('ride_time_seconds'),
min_drop_height=models.Min('max_drop_height_ft'),
max_drop_height=models.Max('max_drop_height_ft'),
min_trains=models.Min('trains_count'),
max_trains=models.Max('trains_count'),
min_cars=models.Min('cars_per_train'),
max_cars=models.Max('cars_per_train'),
min_seats=models.Min('seats_per_car'),
max_seats=models.Max('seats_per_car'),
)
ranges = {
"rating": {
"min": float(ride_stats['min_rating'] or 1),
"max": float(ride_stats['max_rating'] or 10),
"step": 0.1,
"unit": "stars"
},
"height_requirement": { "height_requirement": {
"min": 30, "min": ride_stats['min_height_req'] or 30,
"max": 90, "max": ride_stats['max_height_req'] or 90,
"step": 1, "step": 1,
"unit": "inches", "unit": "inches"
}, },
"capacity": { "capacity": {
"min": 0, "min": ride_stats['min_capacity'] or 0,
"max": 5000, "max": ride_stats['max_capacity'] or 5000,
"step": 50, "step": 50,
"unit": "riders/hour", "unit": "riders/hour"
},
"ride_duration": {
"min": ride_stats['min_duration'] or 0,
"max": ride_stats['max_duration'] or 600,
"step": 10,
"unit": "seconds"
},
"height_ft": {
"min": float(coaster_stats['min_height_ft'] or 0),
"max": float(coaster_stats['max_height_ft'] or 500),
"step": 5,
"unit": "feet"
},
"length_ft": {
"min": float(coaster_stats['min_length_ft'] or 0),
"max": float(coaster_stats['max_length_ft'] or 10000),
"step": 100,
"unit": "feet"
},
"speed_mph": {
"min": float(coaster_stats['min_speed_mph'] or 0),
"max": float(coaster_stats['max_speed_mph'] or 150),
"step": 5,
"unit": "mph"
}, },
"height_ft": {"min": 0, "max": 500, "step": 5, "unit": "feet"},
"speed_mph": {"min": 0, "max": 150, "step": 5, "unit": "mph"},
"inversions": { "inversions": {
"min": 0, "min": coaster_stats['min_inversions'] or 0,
"max": 20, "max": coaster_stats['max_inversions'] or 20,
"step": 1, "step": 1,
"unit": "inversions", "unit": "inversions"
},
"ride_time": {
"min": coaster_stats['min_ride_time'] or 0,
"max": coaster_stats['max_ride_time'] or 600,
"step": 10,
"unit": "seconds"
},
"max_drop_height_ft": {
"min": float(coaster_stats['min_drop_height'] or 0),
"max": float(coaster_stats['max_drop_height'] or 500),
"step": 10,
"unit": "feet"
},
"trains_count": {
"min": coaster_stats['min_trains'] or 1,
"max": coaster_stats['max_trains'] or 10,
"step": 1,
"unit": "trains"
},
"cars_per_train": {
"min": coaster_stats['min_cars'] or 1,
"max": coaster_stats['max_cars'] or 20,
"step": 1,
"unit": "cars"
},
"seats_per_car": {
"min": coaster_stats['min_seats'] or 1,
"max": coaster_stats['max_seats'] or 8,
"step": 1,
"unit": "seats"
}, },
"opening_year": { "opening_year": {
"min": 1800, "min": ride_stats['min_year'] or 1800,
"max": 2030, "max": ride_stats['max_year'] or 2030,
"step": 1, "step": 1,
"unit": "year", "unit": "year"
}, },
}
return Response({
"categories": categories,
"statuses": statuses,
"post_closing_statuses": post_closing_statuses,
"roller_coaster_types": roller_coaster_types,
"track_materials": track_materials,
"launch_types": launch_types,
"ride_model_target_markets": ride_model_target_markets,
"parks": parks,
"park_areas": park_areas,
"manufacturers": manufacturers,
"designers": designers,
"ride_models": ride_models,
"ranges": ranges,
"boolean_filters": [
{"key": "has_inversions", "label": "Has Inversions",
"description": "Filter roller coasters with or without inversions"},
{"key": "has_coordinates", "label": "Has Location Coordinates",
"description": "Filter rides with GPS coordinates"},
{"key": "has_ride_model", "label": "Has Ride Model",
"description": "Filter rides with specified ride model"},
{"key": "has_manufacturer", "label": "Has Manufacturer",
"description": "Filter rides with specified manufacturer"},
{"key": "has_designer", "label": "Has Designer",
"description": "Filter rides with specified designer"},
],
"ordering_options": [
{"value": "name", "label": "Name (A-Z)"},
{"value": "-name", "label": "Name (Z-A)"},
{"value": "opening_date", "label": "Opening Date (Oldest First)"},
{"value": "-opening_date", "label": "Opening Date (Newest First)"},
{"value": "average_rating", "label": "Rating (Lowest First)"},
{"value": "-average_rating", "label": "Rating (Highest First)"},
{"value": "capacity_per_hour", "label": "Capacity (Lowest First)"},
{"value": "-capacity_per_hour",
"label": "Capacity (Highest First)"},
{"value": "ride_duration_seconds",
"label": "Duration (Shortest First)"},
{"value": "-ride_duration_seconds",
"label": "Duration (Longest First)"},
{"value": "height_ft", "label": "Height (Shortest First)"},
{"value": "-height_ft", "label": "Height (Tallest First)"},
{"value": "length_ft", "label": "Length (Shortest First)"},
{"value": "-length_ft", "label": "Length (Longest First)"},
{"value": "speed_mph", "label": "Speed (Slowest First)"},
{"value": "-speed_mph", "label": "Speed (Fastest First)"},
{"value": "inversions", "label": "Inversions (Fewest First)"},
{"value": "-inversions", "label": "Inversions (Most First)"},
{"value": "created_at", "label": "Date Added (Oldest First)"},
{"value": "-created_at", "label": "Date Added (Newest First)"},
{"value": "updated_at", "label": "Last Updated (Oldest First)"},
{"value": "-updated_at", "label": "Last Updated (Newest First)"},
],
})
except Exception:
# Fallback to static options if database query fails
return Response({
"categories": [
{"value": "RC", "label": "Roller Coaster"},
{"value": "DR", "label": "Dark Ride"},
{"value": "FR", "label": "Flat Ride"},
{"value": "WR", "label": "Water Ride"},
{"value": "TR", "label": "Transport"},
{"value": "OT", "label": "Other"},
],
"statuses": [
{"value": "OPERATING", "label": "Operating"},
{"value": "CLOSED_TEMP", "label": "Temporarily Closed"},
{"value": "SBNO", "label": "Standing But Not Operating"},
{"value": "CLOSING", "label": "Closing"},
{"value": "CLOSED_PERM", "label": "Permanently Closed"},
{"value": "UNDER_CONSTRUCTION", "label": "Under Construction"},
{"value": "DEMOLISHED", "label": "Demolished"},
{"value": "RELOCATED", "label": "Relocated"},
],
"post_closing_statuses": [
{"value": "SBNO", "label": "Standing But Not Operating"},
{"value": "CLOSED_PERM", "label": "Permanently Closed"},
],
"roller_coaster_types": [
{"value": "SITDOWN", "label": "Sit Down"},
{"value": "INVERTED", "label": "Inverted"},
{"value": "FLYING", "label": "Flying"},
{"value": "STANDUP", "label": "Stand Up"},
{"value": "WING", "label": "Wing"},
{"value": "DIVE", "label": "Dive"},
{"value": "FAMILY", "label": "Family"},
{"value": "WILD_MOUSE", "label": "Wild Mouse"},
{"value": "SPINNING", "label": "Spinning"},
{"value": "FOURTH_DIMENSION", "label": "4th Dimension"},
{"value": "OTHER", "label": "Other"},
],
"track_materials": [
{"value": "STEEL", "label": "Steel"},
{"value": "WOOD", "label": "Wood"},
{"value": "HYBRID", "label": "Hybrid"},
],
"launch_types": [
{"value": "CHAIN", "label": "Chain Lift"},
{"value": "LSM", "label": "LSM Launch"},
{"value": "HYDRAULIC", "label": "Hydraulic Launch"},
{"value": "GRAVITY", "label": "Gravity"},
{"value": "OTHER", "label": "Other"},
],
"ride_model_target_markets": [
{"value": "FAMILY", "label": "Family"},
{"value": "THRILL", "label": "Thrill"},
{"value": "EXTREME", "label": "Extreme"},
{"value": "KIDDIE", "label": "Kiddie"},
{"value": "ALL_AGES", "label": "All Ages"},
],
"parks": [],
"park_areas": [],
"manufacturers": [],
"designers": [],
"ride_models": [],
"ranges": {
"rating": {"min": 1, "max": 10, "step": 0.1, "unit": "stars"},
"height_requirement": {"min": 30, "max": 90, "step": 1, "unit": "inches"},
"capacity": {"min": 0, "max": 5000, "step": 50, "unit": "riders/hour"},
"ride_duration": {"min": 0, "max": 600, "step": 10, "unit": "seconds"},
"height_ft": {"min": 0, "max": 500, "step": 5, "unit": "feet"},
"length_ft": {"min": 0, "max": 10000, "step": 100, "unit": "feet"},
"speed_mph": {"min": 0, "max": 150, "step": 5, "unit": "mph"},
"inversions": {"min": 0, "max": 20, "step": 1, "unit": "inversions"},
"ride_time": {"min": 0, "max": 600, "step": 10, "unit": "seconds"},
"max_drop_height_ft": {"min": 0, "max": 500, "step": 10, "unit": "feet"},
"trains_count": {"min": 1, "max": 10, "step": 1, "unit": "trains"},
"cars_per_train": {"min": 1, "max": 20, "step": 1, "unit": "cars"},
"seats_per_car": {"min": 1, "max": 8, "step": 1, "unit": "seats"},
"opening_year": {"min": 1800, "max": 2030, "step": 1, "unit": "year"},
}, },
"boolean_filters": [ "boolean_filters": [
{ {"key": "has_inversions", "label": "Has Inversions",
"key": "has_inversions", "description": "Filter roller coasters with or without inversions"},
"label": "Has Inversions", {"key": "has_coordinates", "label": "Has Location Coordinates",
"description": "Filter roller coasters with or without inversions", "description": "Filter rides with GPS coordinates"},
}, {"key": "has_ride_model", "label": "Has Ride Model",
"description": "Filter rides with specified ride model"},
{"key": "has_manufacturer", "label": "Has Manufacturer",
"description": "Filter rides with specified manufacturer"},
{"key": "has_designer", "label": "Has Designer",
"description": "Filter rides with specified designer"},
], ],
} "ordering_options": [
) {"value": "name", "label": "Name (A-Z)"},
{"value": "-name", "label": "Name (Z-A)"},
{"value": "opening_date", "label": "Opening Date (Oldest First)"},
{"value": "-opening_date", "label": "Opening Date (Newest First)"},
{"value": "average_rating", "label": "Rating (Lowest First)"},
{"value": "-average_rating", "label": "Rating (Highest First)"},
{"value": "capacity_per_hour", "label": "Capacity (Lowest First)"},
{"value": "-capacity_per_hour",
"label": "Capacity (Highest First)"},
{"value": "ride_duration_seconds",
"label": "Duration (Shortest First)"},
{"value": "-ride_duration_seconds",
"label": "Duration (Longest First)"},
{"value": "height_ft", "label": "Height (Shortest First)"},
{"value": "-height_ft", "label": "Height (Tallest First)"},
{"value": "length_ft", "label": "Length (Shortest First)"},
{"value": "-length_ft", "label": "Length (Longest First)"},
{"value": "speed_mph", "label": "Speed (Slowest First)"},
{"value": "-speed_mph", "label": "Speed (Fastest First)"},
{"value": "inversions", "label": "Inversions (Fewest First)"},
{"value": "-inversions", "label": "Inversions (Most First)"},
{"value": "created_at", "label": "Date Added (Oldest First)"},
{"value": "-created_at", "label": "Date Added (Newest First)"},
{"value": "updated_at", "label": "Last Updated (Oldest First)"},
{"value": "-updated_at", "label": "Last Updated (Newest First)"},
],
})
# --- Company search (autocomplete) ----------------------------------------- # --- Company search (autocomplete) -----------------------------------------

View File

@@ -14,6 +14,7 @@ from drf_spectacular.utils import (
from config.django import base as settings from config.django import base as settings
from .shared import LocationOutputSerializer, CompanyOutputSerializer, ModelChoices from .shared import LocationOutputSerializer, CompanyOutputSerializer, ModelChoices
from apps.core.services.media_url_service import MediaURLService
# === PARK SERIALIZERS === # === PARK SERIALIZERS ===
@@ -211,20 +212,20 @@ class ParkDetailOutputSerializer(serializers.Serializer):
return [ return [
{ {
"id": photo.id, "id": photo.pk,
"image_url": photo.image.url if photo.image else None, "image_url": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "public"),
"image_variants": ( "image_variants": {
{ "thumbnail": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "thumbnail"),
"thumbnail": ( "medium": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "medium"),
f"{photo.image.url}/thumbnail" if photo.image else None "large": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "large"),
), "public": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "public"),
"medium": f"{photo.image.url}/medium" if photo.image else None, },
"large": f"{photo.image.url}/large" if photo.image else None, "friendly_urls": {
"public": f"{photo.image.url}/public" if photo.image else None, "thumbnail": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "thumbnail"),
} "medium": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "medium"),
if photo.image "large": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "large"),
else {} "public": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "public"),
), },
"caption": photo.caption, "caption": photo.caption,
"alt_text": photo.alt_text, "alt_text": photo.alt_text,
"is_primary": photo.is_primary, "is_primary": photo.is_primary,
@@ -244,13 +245,19 @@ class ParkDetailOutputSerializer(serializers.Serializer):
if photo and photo.image: if photo and photo.image:
return { return {
"id": photo.id, "id": photo.pk,
"image_url": photo.image.url, "image_url": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "public"),
"image_variants": { "image_variants": {
"thumbnail": f"{photo.image.url}/thumbnail", "thumbnail": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "thumbnail"),
"medium": f"{photo.image.url}/medium", "medium": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "medium"),
"large": f"{photo.image.url}/large", "large": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "large"),
"public": f"{photo.image.url}/public", "public": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "public"),
},
"friendly_urls": {
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "thumbnail"),
"medium": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "medium"),
"large": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "large"),
"public": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "public"),
}, },
"caption": photo.caption, "caption": photo.caption,
"alt_text": photo.alt_text, "alt_text": photo.alt_text,
@@ -266,13 +273,19 @@ class ParkDetailOutputSerializer(serializers.Serializer):
# First try the explicitly set banner image # First try the explicitly set banner image
if obj.banner_image and obj.banner_image.image: if obj.banner_image and obj.banner_image.image:
return { return {
"id": obj.banner_image.id, "id": obj.banner_image.pk,
"image_url": obj.banner_image.image.url, "image_url": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "public"),
"image_variants": { "image_variants": {
"thumbnail": f"{obj.banner_image.image.url}/thumbnail", "thumbnail": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "thumbnail"),
"medium": f"{obj.banner_image.image.url}/medium", "medium": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "medium"),
"large": f"{obj.banner_image.image.url}/large", "large": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "large"),
"public": f"{obj.banner_image.image.url}/public", "public": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "public"),
},
"friendly_urls": {
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, obj.banner_image.caption, obj.banner_image.pk, "thumbnail"),
"medium": MediaURLService.generate_park_photo_url(obj.slug, obj.banner_image.caption, obj.banner_image.pk, "medium"),
"large": MediaURLService.generate_park_photo_url(obj.slug, obj.banner_image.caption, obj.banner_image.pk, "large"),
"public": MediaURLService.generate_park_photo_url(obj.slug, obj.banner_image.caption, obj.banner_image.pk, "public"),
}, },
"caption": obj.banner_image.caption, "caption": obj.banner_image.caption,
"alt_text": obj.banner_image.alt_text, "alt_text": obj.banner_image.alt_text,
@@ -292,13 +305,19 @@ class ParkDetailOutputSerializer(serializers.Serializer):
if latest_photo and latest_photo.image: if latest_photo and latest_photo.image:
return { return {
"id": latest_photo.id, "id": latest_photo.pk,
"image_url": latest_photo.image.url, "image_url": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "public"),
"image_variants": { "image_variants": {
"thumbnail": f"{latest_photo.image.url}/thumbnail", "thumbnail": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "thumbnail"),
"medium": f"{latest_photo.image.url}/medium", "medium": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "medium"),
"large": f"{latest_photo.image.url}/large", "large": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "large"),
"public": f"{latest_photo.image.url}/public", "public": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "public"),
},
"friendly_urls": {
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "thumbnail"),
"medium": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "medium"),
"large": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "large"),
"public": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "public"),
}, },
"caption": latest_photo.caption, "caption": latest_photo.caption,
"alt_text": latest_photo.alt_text, "alt_text": latest_photo.alt_text,
@@ -315,13 +334,19 @@ class ParkDetailOutputSerializer(serializers.Serializer):
# First try the explicitly set card image # First try the explicitly set card image
if obj.card_image and obj.card_image.image: if obj.card_image and obj.card_image.image:
return { return {
"id": obj.card_image.id, "id": obj.card_image.pk,
"image_url": obj.card_image.image.url, "image_url": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "public"),
"image_variants": { "image_variants": {
"thumbnail": f"{obj.card_image.image.url}/thumbnail", "thumbnail": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "thumbnail"),
"medium": f"{obj.card_image.image.url}/medium", "medium": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "medium"),
"large": f"{obj.card_image.image.url}/large", "large": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "large"),
"public": f"{obj.card_image.image.url}/public", "public": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "public"),
},
"friendly_urls": {
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, obj.card_image.caption, obj.card_image.pk, "thumbnail"),
"medium": MediaURLService.generate_park_photo_url(obj.slug, obj.card_image.caption, obj.card_image.pk, "medium"),
"large": MediaURLService.generate_park_photo_url(obj.slug, obj.card_image.caption, obj.card_image.pk, "large"),
"public": MediaURLService.generate_park_photo_url(obj.slug, obj.card_image.caption, obj.card_image.pk, "public"),
}, },
"caption": obj.card_image.caption, "caption": obj.card_image.caption,
"alt_text": obj.card_image.alt_text, "alt_text": obj.card_image.alt_text,
@@ -341,13 +366,19 @@ class ParkDetailOutputSerializer(serializers.Serializer):
if latest_photo and latest_photo.image: if latest_photo and latest_photo.image:
return { return {
"id": latest_photo.id, "id": latest_photo.pk,
"image_url": latest_photo.image.url, "image_url": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "public"),
"image_variants": { "image_variants": {
"thumbnail": f"{latest_photo.image.url}/thumbnail", "thumbnail": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "thumbnail"),
"medium": f"{latest_photo.image.url}/medium", "medium": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "medium"),
"large": f"{latest_photo.image.url}/large", "large": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "large"),
"public": f"{latest_photo.image.url}/public", "public": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "public"),
},
"friendly_urls": {
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "thumbnail"),
"medium": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "medium"),
"large": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "large"),
"public": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "public"),
}, },
"caption": latest_photo.caption, "caption": latest_photo.caption,
"alt_text": latest_photo.alt_text, "alt_text": latest_photo.alt_text,

View File

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

View File

@@ -1,6 +1,9 @@
c# Active Context c# Active Context
## Current Focus ## Current Focus
- **✅ COMPLETED: Park Detail Endpoint with Full Request Properties Documentation**: Successfully enhanced the existing park detail endpoint to support both ID and slug-based lookup (including historical slugs) and created comprehensive documentation covering all possible request properties and response structure
- **✅ COMPLETED: Comprehensive Rides Filter Options Endpoint**: Successfully applied the same comprehensive enhancement process to the rides filter-options endpoint, exposing all possible ride model fields and attributes read-only with dynamic data from database
- **✅ COMPLETED: Comprehensive Park Filter Options Endpoint**: Successfully updated the parks filter-options endpoint to expose all possible park model fields and attributes read-only, including all park types, statuses, location data, company information, and dynamic ranges
- **✅ COMPLETED: Parks and Rides API 501 Error Fix**: Successfully resolved 501 errors in both parks and rides listing endpoints by fixing import paths from `apps.companies.models` to `apps.parks.models` and resolving annotation conflicts with existing model fields - **✅ COMPLETED: Parks and Rides API 501 Error Fix**: Successfully resolved 501 errors in both parks and rides listing endpoints by fixing import paths from `apps.companies.models` to `apps.parks.models` and resolving annotation conflicts with existing model fields
- **✅ COMPLETED: Park Filter Endpoints Backend-Frontend Alignment**: Successfully resolved critical backend-frontend alignment issue where Django backend was filtering on non-existent model fields - **✅ COMPLETED: Park Filter Endpoints Backend-Frontend Alignment**: Successfully resolved critical backend-frontend alignment issue where Django backend was filtering on non-existent model fields
- **✅ COMPLETED: Automatic Cloudflare Image Deletion**: Successfully implemented automatic Cloudflare image deletion across all photo upload systems (avatar, park photos, ride photos) when users change or remove images - **✅ COMPLETED: Automatic Cloudflare Image Deletion**: Successfully implemented automatic Cloudflare image deletion across all photo upload systems (avatar, park photos, ride photos) when users change or remove images

View File

@@ -287,8 +287,25 @@ The moderation system provides comprehensive content moderation, user management
- **Returns**: Search suggestions for park names - **Returns**: Search suggestions for park names
### Park Details ### Park Details
- **GET** `/api/v1/parks/{slug}/` - **GET** `/api/v1/parks/{identifier}/`
- **Returns**: Complete park information including rides, photos, and statistics - **Description**: Retrieve comprehensive park details including location, photos, areas, rides, and company information
- **Supports Multiple Lookup Methods**:
- By ID: `/api/v1/parks/123/`
- By current slug: `/api/v1/parks/cedar-point/`
- By historical slug: `/api/v1/parks/old-cedar-point-name/`
- **Query Parameters**: None required - returns full details by default
- **Returns**: Complete park information including:
- Core park details (name, slug, description, status, park_type)
- Operational details (opening/closing dates, size, website)
- Statistics (average rating, ride count, coaster count)
- Full location data with coordinates and formatted address
- Operating company and property owner information
- Park areas/themed sections
- All approved photos with Cloudflare variants
- Primary, banner, and card image designations
- Frontend URL and metadata
- **Authentication**: None required (public endpoint)
- **Documentation**: See `docs/park-detail-endpoint-documentation.md` for complete details
### Park Rides ### Park Rides
- **GET** `/api/v1/parks/{park_slug}/rides/` - **GET** `/api/v1/parks/{park_slug}/rides/`

View File

@@ -0,0 +1,510 @@
# Park Detail Endpoint - Complete Documentation
## Endpoint Overview
**URL:** `GET /api/v1/parks/{identifier}/`
**Description:** Retrieve comprehensive park details including location, photos, areas, rides, and company information.
**Authentication:** None required (public endpoint)
**Supports Multiple Lookup Methods:**
- By ID: `/api/v1/parks/123/`
- By current slug: `/api/v1/parks/cedar-point/`
- By historical slug: `/api/v1/parks/old-cedar-point-name/`
## Request Properties
### Path Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `identifier` | string | Yes | Park ID (integer) or slug (string). Supports current and historical slugs. |
### Query Parameters
**None required** - This endpoint returns full park details by default without any query parameters.
### Request Headers
| Header | Required | Description |
|--------|----------|-------------|
| `Accept` | No | `application/json` (default) |
| `Content-Type` | No | Not applicable for GET requests |
## Response Structure
### Success Response (200 OK)
```json
{
"id": 1,
"name": "Cedar Point",
"slug": "cedar-point",
"status": "OPERATING",
"description": "America's Roller Coast",
"park_type": "THEME_PARK",
// Dates and Operations
"opening_date": "1870-01-01",
"closing_date": null,
"operating_season": "May - October",
"size_acres": 364.0,
"website": "https://cedarpoint.com",
// Statistics
"average_rating": 4.5,
"coaster_count": 17,
"ride_count": 70,
// Location Information
"location": {
"id": 1,
"latitude": 41.4793,
"longitude": -82.6833,
"street_address": "1 Cedar Point Dr",
"city": "Sandusky",
"state": "Ohio",
"country": "United States",
"continent": "North America",
"postal_code": "44870",
"formatted_address": "1 Cedar Point Dr, Sandusky, OH 44870, United States"
},
// Company Information
"operator": {
"id": 1,
"name": "Cedar Fair",
"slug": "cedar-fair",
"roles": ["OPERATOR"],
"description": "Leading amusement park operator",
"website": "https://cedarfair.com",
"founded_year": 1983
},
"property_owner": {
"id": 1,
"name": "Cedar Fair",
"slug": "cedar-fair",
"roles": ["OPERATOR", "PROPERTY_OWNER"],
"description": "Leading amusement park operator",
"website": "https://cedarfair.com",
"founded_year": 1983
},
// Park Areas/Themed Sections
"areas": [
{
"id": 1,
"name": "Frontier Town",
"slug": "frontier-town",
"description": "Wild West themed area"
},
{
"id": 2,
"name": "Millennium Island",
"slug": "millennium-island",
"description": "Home to Millennium Force"
}
],
// Photo Information
"photos": [
{
"id": 456,
"image_url": "https://imagedelivery.net/account-hash/def789ghi012/public",
"image_variants": {
"thumbnail": "https://imagedelivery.net/account-hash/def789ghi012/thumbnail",
"medium": "https://imagedelivery.net/account-hash/def789ghi012/medium",
"large": "https://imagedelivery.net/account-hash/def789ghi012/large",
"public": "https://imagedelivery.net/account-hash/def789ghi012/public"
},
"friendly_urls": {
"thumbnail": "/parks/cedar-point/photos/beautiful-park-entrance-456-thumbnail.jpg",
"medium": "/parks/cedar-point/photos/beautiful-park-entrance-456-medium.jpg",
"large": "/parks/cedar-point/photos/beautiful-park-entrance-456-large.jpg",
"public": "/parks/cedar-point/photos/beautiful-park-entrance-456.jpg"
},
"caption": "Beautiful park entrance",
"alt_text": "Cedar Point main entrance with flags",
"is_primary": true
}
],
// Primary Photo (designated main photo)
"primary_photo": {
"id": 456,
"image_url": "https://imagedelivery.net/account-hash/def789ghi012/public",
"image_variants": {
"thumbnail": "https://imagedelivery.net/account-hash/def789ghi012/thumbnail",
"medium": "https://imagedelivery.net/account-hash/def789ghi012/medium",
"large": "https://imagedelivery.net/account-hash/def789ghi012/large",
"public": "https://imagedelivery.net/account-hash/def789ghi012/public"
},
"caption": "Beautiful park entrance",
"alt_text": "Cedar Point main entrance with flags"
},
// Banner Image (for hero sections)
"banner_image": {
"id": 456,
"image_url": "https://imagedelivery.net/account-hash/def789ghi012/public",
"image_variants": {
"thumbnail": "https://imagedelivery.net/account-hash/def789ghi012/thumbnail",
"medium": "https://imagedelivery.net/account-hash/def789ghi012/medium",
"large": "https://imagedelivery.net/account-hash/def789ghi012/large",
"public": "https://imagedelivery.net/account-hash/def789ghi012/public"
},
"caption": "Beautiful park entrance",
"alt_text": "Cedar Point main entrance with flags",
"is_fallback": false
},
// Card Image (for listings/cards)
"card_image": {
"id": 456,
"image_url": "https://imagedelivery.net/account-hash/def789ghi012/public",
"image_variants": {
"thumbnail": "https://imagedelivery.net/account-hash/def789ghi012/thumbnail",
"medium": "https://imagedelivery.net/account-hash/def789ghi012/medium",
"large": "https://imagedelivery.net/account-hash/def789ghi012/large",
"public": "https://imagedelivery.net/account-hash/def789ghi012/public"
},
"caption": "Beautiful park entrance",
"alt_text": "Cedar Point main entrance with flags",
"is_fallback": false
},
// Frontend URL
"url": "https://thrillwiki.com/parks/cedar-point/",
// Metadata
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-15T12:30:00Z"
}
```
### Error Responses
#### 404 Not Found
```json
{
"detail": "Park not found"
}
```
#### 500 Internal Server Error
```json
{
"detail": "Internal server error"
}
```
## Field Descriptions
### Core Park Information
| Field | Type | Description |
|-------|------|-------------|
| `id` | integer | Unique park identifier |
| `name` | string | Official park name |
| `slug` | string | URL-friendly identifier |
| `status` | string | Operational status (see Status Values) |
| `description` | string | Park description/tagline |
| `park_type` | string | Park category (see Park Type Values) |
### Operational Details
| Field | Type | Description |
|-------|------|-------------|
| `opening_date` | date | Park opening date (YYYY-MM-DD) |
| `closing_date` | date | Park closing date (null if still operating) |
| `operating_season` | string | Seasonal operation description |
| `size_acres` | decimal | Park size in acres |
| `website` | string | Official park website URL |
### Statistics
| Field | Type | Description |
|-------|------|-------------|
| `average_rating` | decimal | Average user rating (1-10 scale) |
| `coaster_count` | integer | Number of roller coasters |
| `ride_count` | integer | Total number of rides |
### Location Object
| Field | Type | Description |
|-------|------|-------------|
| `id` | integer | Location record ID |
| `latitude` | float | Geographic latitude |
| `longitude` | float | Geographic longitude |
| `street_address` | string | Street address |
| `city` | string | City name |
| `state` | string | State/province |
| `country` | string | Country name |
| `continent` | string | Continent name |
| `postal_code` | string | ZIP/postal code |
| `formatted_address` | string | Complete formatted address |
### Company Objects (Operator/Property Owner)
| Field | Type | Description |
|-------|------|-------------|
| `id` | integer | Company ID |
| `name` | string | Company name |
| `slug` | string | URL-friendly identifier |
| `roles` | array | Company roles (OPERATOR, PROPERTY_OWNER, etc.) |
| `description` | string | Company description |
| `website` | string | Company website |
| `founded_year` | integer | Year company was founded |
### Area Objects
| Field | Type | Description |
|-------|------|-------------|
| `id` | integer | Area ID |
| `name` | string | Area/section name |
| `slug` | string | URL-friendly identifier |
| `description` | string | Area description |
### Photo Objects
| Field | Type | Description |
|-------|------|-------------|
| `id` | integer | Photo ID |
| `image_url` | string | Base Cloudflare image URL |
| `image_variants` | object | Available image sizes/transformations |
| `caption` | string | Photo caption |
| `alt_text` | string | Accessibility alt text |
| `is_primary` | boolean | Whether this is the primary photo |
| `is_fallback` | boolean | Whether this is a fallback image |
### Image Variants Object
| Field | Type | Description |
|-------|------|-------------|
| `thumbnail` | string | Small thumbnail URL (150x150) |
| `medium` | string | Medium size URL (500x500) |
| `large` | string | Large size URL (1200x1200) |
| `public` | string | Full size public URL |
## Enumerated Values
### Status Values
| Value | Description |
|-------|-------------|
| `OPERATING` | Currently operating |
| `CLOSED_TEMP` | Temporarily closed |
| `CLOSED_PERM` | Permanently closed |
| `UNDER_CONSTRUCTION` | Under construction |
| `DEMOLISHED` | Demolished |
| `RELOCATED` | Relocated |
### Park Type Values
| Value | Description |
|-------|-------------|
| `THEME_PARK` | Theme park |
| `AMUSEMENT_PARK` | Amusement park |
| `WATER_PARK` | Water park |
| `FAMILY_ENTERTAINMENT_CENTER` | Family entertainment center |
| `CARNIVAL` | Carnival |
| `FAIR` | Fair |
| `PIER` | Pier |
| `BOARDWALK` | Boardwalk |
| `SAFARI_PARK` | Safari park |
| `ZOO` | Zoo |
| `OTHER` | Other |
## Usage Examples
### JavaScript/TypeScript
```typescript
// Fetch by ID
const parkById = await fetch('/api/v1/parks/123/');
const parkData = await parkById.json();
// Fetch by current slug
const parkBySlug = await fetch('/api/v1/parks/cedar-point/');
const parkData2 = await parkBySlug.json();
// Fetch by historical slug
const parkByHistoricalSlug = await fetch('/api/v1/parks/old-name/');
const parkData3 = await parkByHistoricalSlug.json();
// Access different image sizes
const thumbnailUrl = parkData.primary_photo?.image_variants.thumbnail;
const fullSizeUrl = parkData.primary_photo?.image_variants.public;
```
### Python
```python
import requests
# Fetch park details
response = requests.get('https://api.thrillwiki.com/api/v1/parks/cedar-point/')
park_data = response.json()
# Access park information
park_name = park_data['name']
location = park_data['location']
operator = park_data['operator']
photos = park_data['photos']
```
### cURL
```bash
# Fetch by slug
curl -X GET "https://api.thrillwiki.com/api/v1/parks/cedar-point/" \
-H "Accept: application/json"
# Fetch by ID
curl -X GET "https://api.thrillwiki.com/api/v1/parks/123/" \
-H "Accept: application/json"
```
## Related Endpoints
- **Park List:** `GET /api/v1/parks/` - List parks with filtering
- **Park Photos:** `GET /api/v1/parks/{id}/photos/` - Manage park photos
- **Park Areas:** `GET /api/v1/parks/{id}/areas/` - Park themed areas
- **Park Image Settings:** `PATCH /api/v1/parks/{id}/image-settings/` - Set banner/card images
## Photo Handling Details
### Photo Upload vs Display Distinction
**Important**: You can upload unlimited photos per park, but the park detail endpoint shows only the 10 most relevant photos for performance optimization.
#### **Photo Upload Capacity**
- **No Upload Limit**: Upload unlimited photos per park via `POST /api/v1/parks/{park_id}/photos/`
- **Storage**: All photos stored in database and Cloudflare Images
- **Approval System**: Each photo goes through moderation (`is_approved` field)
- **Photo Types**: Categorize photos (banner, card, gallery, etc.)
- **Bulk Upload**: Support for multiple photo uploads
#### **Display Limit (Detail Endpoint)**
- **10 Photo Limit**: Only applies to this park detail endpoint response
- **Smart Selection**: Shows 10 most relevant photos using intelligent ordering:
1. **Primary photos first** (`-is_primary`)
2. **Newest photos next** (`-created_at`)
3. **Only approved photos** (`is_approved=True`)
### Complete Photo Access
#### **All Photos Available Via Dedicated Endpoint**
```
GET /api/v1/parks/{park_id}/photos/
```
- **No Limit**: Returns all uploaded photos for the park
- **Pagination**: Supports pagination for large photo collections
- **Filtering**: Filter by photo type, approval status, etc.
- **Full Management**: Complete CRUD operations for all photos
#### **Photo URL Structure Per Park**
**Maximum Possible URLs per park:**
- **General photos**: 10 photos × 4 variants = **40 URLs**
- **Primary photo**: 1 photo × 4 variants = **4 URLs**
- **Banner image**: 1 photo × 4 variants = **4 URLs**
- **Card image**: 1 photo × 4 variants = **4 URLs**
- **Total Maximum**: **52 photo URLs per park**
**Each photo includes 4 Cloudflare transformation URLs:**
1. **`thumbnail`**: Optimized for small previews (150x150)
2. **`medium`**: Medium resolution for general use (500x500)
3. **`large`**: High resolution for detailed viewing (1200x1200)
4. **`public`**: Original/full size image
#### **Practical Example**
A park could have:
- **50 uploaded photos** (all stored in system)
- **30 approved photos** (available for public display)
- **10 photos shown** in park detail endpoint (most relevant)
- **All 30 approved photos** accessible via `/api/v1/parks/{id}/photos/`
#### **Frontend Implementation Strategy**
```javascript
// Get park with essential photos (fast initial load)
const park = await fetch('/api/v1/parks/cedar-point/');
// Get complete photo gallery when needed (e.g., photo gallery page)
const allPhotos = await fetch('/api/v1/parks/123/photos/?page_size=50');
```
### Friendly URLs for Photos
**NEW FEATURE**: Each photo now includes both Cloudflare URLs and SEO-friendly URLs.
#### **URL Structure**
```
/parks/{park-slug}/photos/{caption-slug}-{photo-id}-{variant}.jpg
```
**Examples:**
- `/parks/cedar-point/photos/beautiful-park-entrance-456.jpg` (public/original)
- `/parks/cedar-point/photos/beautiful-park-entrance-456-thumbnail.jpg`
- `/parks/cedar-point/photos/beautiful-park-entrance-456-medium.jpg`
- `/parks/cedar-point/photos/beautiful-park-entrance-456-large.jpg`
#### **Benefits**
- **SEO Optimized**: Descriptive URLs improve search engine ranking
- **User Friendly**: URLs are readable and meaningful
- **Consistent**: Follows predictable pattern across all photos
- **Backwards Compatible**: Original Cloudflare URLs still available
#### **Implementation**
Each photo object now includes both URL types:
```json
{
"id": 456,
"image_url": "https://imagedelivery.net/account-hash/def789ghi012/public",
"image_variants": {
"thumbnail": "https://imagedelivery.net/account-hash/def789ghi012/thumbnail",
"medium": "https://imagedelivery.net/account-hash/def789ghi012/medium",
"large": "https://imagedelivery.net/account-hash/def789ghi012/large",
"public": "https://imagedelivery.net/account-hash/def789ghi012/public"
},
"friendly_urls": {
"thumbnail": "/parks/cedar-point/photos/beautiful-park-entrance-456-thumbnail.jpg",
"medium": "/parks/cedar-point/photos/beautiful-park-entrance-456-medium.jpg",
"large": "/parks/cedar-point/photos/beautiful-park-entrance-456-large.jpg",
"public": "/parks/cedar-point/photos/beautiful-park-entrance-456.jpg"
},
"caption": "Beautiful park entrance",
"alt_text": "Cedar Point main entrance with flags"
}
```
### Photo Management Features
- **Primary Photo**: Designate which photo represents the park
- **Banner/Card Images**: Set specific photos for different UI contexts
- **Fallback Logic**: Banner and card images automatically fallback to latest approved photo if not explicitly set
- **Approval Workflow**: Moderate photos before public display
- **Photo Metadata**: Each photo includes caption, alt text, and categorization
- **Dual URL System**: Both Cloudflare and friendly URLs provided for maximum flexibility
## Performance Notes
- Response includes optimized database queries with `select_related` and `prefetch_related`
- Photos limited to 10 most recent approved photos for optimal response size
- Image variants are pre-computed Cloudflare transformations for fast delivery
- Historical slug lookup may require additional database queries
- Smart photo selection ensures most relevant photos are included
## Caching
- No caching implemented at endpoint level
- Cloudflare images are cached at CDN level
- Consider implementing Redis caching for frequently accessed parks
## Rate Limiting
- No rate limiting currently implemented
- Public endpoint accessible without authentication
- Consider implementing rate limiting for production use