mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 05:51:08 -05:00
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:
@@ -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.
|
||||
- **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.
|
||||
|
||||
### Data Model Rules
|
||||
- **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
|
||||
- Validate all endpoint URLs against the mandatory trailing slash rule
|
||||
- **RIDE TYPES vs RIDE MODELS**: These are separate concepts for ALL ride categories:
|
||||
- **Ride Types**: How rides operate (e.g., "inverted", "trackless", "spinning", "log flume", "monorail")
|
||||
- **Ride Models**: Specific manufacturer products (e.g., "B&M Dive Coaster", "Vekoma Boomerang")
|
||||
- Individual rides reference BOTH the model (what product) and type (how it operates)
|
||||
- Ride types must be available for ALL ride categories, not just roller coasters
|
||||
|
||||
### Development Commands
|
||||
## Development Commands and Code Quality
|
||||
- **Django Server**: Always use `uv run manage.py runserver_plus` instead of `python manage.py runserver`
|
||||
- **Django Migrations**: Always use `uv run manage.py makemigrations` and `uv run manage.py migrate` instead of `python manage.py`
|
||||
- **Package Management**: Always use `uv add <package>` instead of `pip install <package>`
|
||||
- **Django Management**: Always use `uv run manage.py <command>` instead of `python manage.py <command>`
|
||||
- Break down methods with high cognitive complexity (>15) into smaller, focused helper methods
|
||||
- Extract logical operations into separate methods with descriptive names
|
||||
- Use single responsibility principle - each method should have one clear purpose
|
||||
- Prefer composition over deeply nested conditional logic
|
||||
- Always handle None values explicitly to avoid type errors
|
||||
- Use proper type annotations, including union types (e.g., `Polygon | None`)
|
||||
- Structure API views with clear separation between parameter handling, business logic, and response building
|
||||
- When addressing SonarQube or linting warnings, focus on structural improvements rather than quick fixes
|
||||
|
||||
### ThrillWiki Project Rules
|
||||
## ThrillWiki Project Rules
|
||||
- **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
|
||||
- **Tracking**: All models use pghistory for change tracking and TrackedModel base class
|
||||
@@ -30,38 +84,8 @@
|
||||
- **Search Integration**: Text search, filtering, autocomplete endpoints, pagination
|
||||
- **Statistics**: Cached stats endpoints with automatic invalidation via Django signals
|
||||
|
||||
### CRITICAL DOCUMENTATION RULE
|
||||
- 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.
|
||||
- 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
|
||||
## CRITICAL RULES
|
||||
- **DOCUMENTATION**: After every change, it is MANDATORY to update docs/frontend.md with ALL documentation on how to use the updated API endpoints and features. It is MANDATORY to include any types in docs/types-api.ts for NextJS as the file would appear in `src/types/api.ts`. It is MANDATORY to include any new API endpoints in docs/lib-api.ts for NextJS as the file would appear in `/src/lib/api.ts`. Maintain accuracy and compliance in all technical documentation. Ensure API documentation matches backend URL routing expectations.
|
||||
- **NEVER MOCK DATA**: You are NEVER EVER to mock any data unless it's ONLY for API schema documentation purposes. All data must come from real database queries and actual model instances. Mock data is STRICTLY FORBIDDEN in all API responses, services, and business logic.
|
||||
|
||||
### CRITICAL DOMAIN SEPARATION RULE
|
||||
- **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.
|
||||
- **DOMAIN SEPARATION**: Company roles OPERATOR and PROPERTY_OWNER are EXCLUSIVELY for parks domain. They should NEVER be used in rides URLs or ride-related contexts. Only MANUFACTURER and DESIGNER roles are for rides domain. Parks: `/parks/{park_slug}/` and `/parks/`. Rides: `/parks/{park_slug}/rides/{ride_slug}/` and `/rides/`. Parks Companies: `/parks/operators/{operator_slug}/` and `/parks/owners/{owner_slug}/`. Rides Companies: `/rides/manufacturers/{manufacturer_slug}/` and `/rides/designers/{designer_slug}/`. NEVER mix these domains - this is a fundamental and DANGEROUS business rule violation.
|
||||
- **PHOTO MANAGEMENT**: Use CloudflareImagesField for all photo uploads with variants and transformations. Clearly define and use photo types (e.g., banner, card) for all images. Include attribution fields for all photos. Implement logic to determine the primary photo for each model.
|
||||
|
||||
@@ -1,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -12,6 +12,7 @@ Supports all 24 filtering parameters from frontend API documentation.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from django.db import models
|
||||
from django.db.models import Q, Count, Avg
|
||||
from django.db.models.query import QuerySet
|
||||
|
||||
@@ -401,20 +402,22 @@ class ParkListCreateAPIView(APIView):
|
||||
|
||||
# --- Park retrieve / update / delete ---------------------------------------
|
||||
@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={
|
||||
200: (
|
||||
"ParkDetailOutputSerializer()"
|
||||
if SERIALIZERS_AVAILABLE
|
||||
else OpenApiTypes.OBJECT
|
||||
)
|
||||
),
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Parks"],
|
||||
)
|
||||
class ParkDetailAPIView(APIView):
|
||||
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:
|
||||
raise NotFound(
|
||||
(
|
||||
@@ -423,13 +426,77 @@ class ParkDetailAPIView(APIView):
|
||||
"to enable detail endpoints."
|
||||
)
|
||||
)
|
||||
|
||||
# Try to parse as integer ID first
|
||||
try:
|
||||
# type: ignore
|
||||
return Park.objects.select_related("operator", "property_owner").get(pk=pk)
|
||||
except Park.DoesNotExist: # type: ignore
|
||||
raise NotFound("Park not found")
|
||||
pk = int(identifier)
|
||||
try:
|
||||
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")
|
||||
|
||||
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)
|
||||
if SERIALIZERS_AVAILABLE:
|
||||
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)
|
||||
if not SERIALIZERS_AVAILABLE:
|
||||
return Response(
|
||||
@@ -478,11 +545,11 @@ class ParkDetailAPIView(APIView):
|
||||
serializer = ParkDetailOutputSerializer(park, context={"request": request})
|
||||
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
|
||||
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:
|
||||
return Response(
|
||||
{
|
||||
@@ -508,9 +575,9 @@ class FilterOptionsAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
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:
|
||||
# Fallback comprehensive options
|
||||
# Fallback comprehensive options with all possible fields
|
||||
return Response({
|
||||
"park_types": [
|
||||
{"value": "THEME_PARK", "label": "Theme Park"},
|
||||
@@ -518,6 +585,21 @@ class FilterOptionsAPIView(APIView):
|
||||
{"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": [
|
||||
"North America",
|
||||
@@ -546,6 +628,21 @@ class FilterOptionsAPIView(APIView):
|
||||
"Texas",
|
||||
"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": [
|
||||
{"value": "name", "label": "Name (A-Z)"},
|
||||
{"value": "-name", "label": "Name (Z-A)"},
|
||||
@@ -553,18 +650,36 @@ class FilterOptionsAPIView(APIView):
|
||||
{"value": "-opening_date", "label": "Opening Date (Newest First)"},
|
||||
{"value": "ride_count", "label": "Ride Count (Low to High)"},
|
||||
{"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)"},
|
||||
{"value": "roller_coaster_count",
|
||||
"label": "Coaster Count (Low to High)"},
|
||||
{"value": "-roller_coaster_count",
|
||||
"label": "Coaster Count (High to Low)"},
|
||||
{"value": "size_acres", "label": "Size (Small to Large)"},
|
||||
{"value": "-size_acres", "label": "Size (Large to Small)"},
|
||||
{"value": "created_at",
|
||||
"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:
|
||||
# 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(
|
||||
location__continent__isnull=True
|
||||
).exclude(
|
||||
@@ -595,17 +710,78 @@ class FilterOptionsAPIView(APIView):
|
||||
location__state__exact=''
|
||||
).values_list('location__state', flat=True).distinct().order_by('location__state'))
|
||||
|
||||
# Get park types from model choices (now available field)
|
||||
park_types = [
|
||||
{"value": choice[0], "label": choice[1]}
|
||||
for choice in Park.PARK_TYPE_CHOICES
|
||||
]
|
||||
cities = list(Park.objects.exclude(
|
||||
location__city__isnull=True
|
||||
).exclude(
|
||||
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({
|
||||
"park_types": park_types,
|
||||
"statuses": statuses,
|
||||
"continents": continents,
|
||||
"countries": countries,
|
||||
"states": states,
|
||||
"cities": cities,
|
||||
"operators": operators,
|
||||
"property_owners": property_owners,
|
||||
"ranges": ranges,
|
||||
"ordering_options": [
|
||||
{"value": "name", "label": "Name (A-Z)"},
|
||||
{"value": "-name", "label": "Name (Z-A)"},
|
||||
@@ -613,12 +789,18 @@ class FilterOptionsAPIView(APIView):
|
||||
{"value": "-opening_date", "label": "Opening Date (Newest First)"},
|
||||
{"value": "ride_count", "label": "Ride Count (Low to High)"},
|
||||
{"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)"},
|
||||
{"value": "roller_coaster_count",
|
||||
"label": "Coaster Count (Low to High)"},
|
||||
{"value": "-roller_coaster_count",
|
||||
"label": "Coaster Count (High to Low)"},
|
||||
{"value": "size_acres", "label": "Size (Small to Large)"},
|
||||
{"value": "-size_acres", "label": "Size (Large to Small)"},
|
||||
{"value": "created_at",
|
||||
"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": "AMUSEMENT_PARK", "label": "Amusement 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": [
|
||||
"North America",
|
||||
@@ -652,6 +851,20 @@ class FilterOptionsAPIView(APIView):
|
||||
"Ohio",
|
||||
"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": [
|
||||
{"value": "name", "label": "Name (A-Z)"},
|
||||
{"value": "-name", "label": "Name (Z-A)"},
|
||||
@@ -659,6 +872,10 @@ class FilterOptionsAPIView(APIView):
|
||||
{"value": "-opening_date", "label": "Opening Date (Newest First)"},
|
||||
{"value": "ride_count", "label": "Ride Count (Low to High)"},
|
||||
{"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)"},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -41,8 +41,8 @@ urlpatterns = [
|
||||
ParkSearchSuggestionsAPIView.as_view(),
|
||||
name="park-search-suggestions",
|
||||
),
|
||||
# Detail and action endpoints
|
||||
path("<int:pk>/", ParkDetailAPIView.as_view(), name="park-detail"),
|
||||
# Detail and action endpoints - supports both ID and slug
|
||||
path("<str:pk>/", ParkDetailAPIView.as_view(), name="park-detail"),
|
||||
# Park image settings endpoint
|
||||
path(
|
||||
"<int:pk>/image-settings/",
|
||||
|
||||
@@ -680,7 +680,7 @@ class RideDetailAPIView(APIView):
|
||||
# --- Filter options ---------------------------------------------------------
|
||||
@extend_schema(
|
||||
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},
|
||||
tags=["Rides"],
|
||||
)
|
||||
@@ -688,132 +688,96 @@ class FilterOptionsAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Return comprehensive filter options used by the frontend."""
|
||||
# Try to use ModelChoices if available
|
||||
if HAVE_MODELCHOICES and ModelChoices is not None:
|
||||
try:
|
||||
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(
|
||||
{
|
||||
"""Return comprehensive filter options with all possible ride model fields and attributes."""
|
||||
if not MODELS_AVAILABLE:
|
||||
# Comprehensive fallback options with all possible fields
|
||||
return Response({
|
||||
"categories": [
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport"),
|
||||
("OT", "Other"),
|
||||
{"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": [
|
||||
("OPERATING", "Operating"),
|
||||
("CLOSED_TEMP", "Temporarily Closed"),
|
||||
("SBNO", "Standing But Not Operating"),
|
||||
("CLOSING", "Closing"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
("UNDER_CONSTRUCTION", "Under Construction"),
|
||||
("DEMOLISHED", "Demolished"),
|
||||
("RELOCATED", "Relocated"),
|
||||
{"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": [
|
||||
("SITDOWN", "Sit Down"),
|
||||
("INVERTED", "Inverted"),
|
||||
("FLYING", "Flying"),
|
||||
("STANDUP", "Stand Up"),
|
||||
("WING", "Wing"),
|
||||
("DIVE", "Dive"),
|
||||
("FAMILY", "Family"),
|
||||
("WILD_MOUSE", "Wild Mouse"),
|
||||
("SPINNING", "Spinning"),
|
||||
("FOURTH_DIMENSION", "4th Dimension"),
|
||||
("OTHER", "Other"),
|
||||
{"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": [
|
||||
("STEEL", "Steel"),
|
||||
("WOOD", "Wood"),
|
||||
("HYBRID", "Hybrid"),
|
||||
{"value": "STEEL", "label": "Steel"},
|
||||
{"value": "WOOD", "label": "Wood"},
|
||||
{"value": "HYBRID", "label": "Hybrid"},
|
||||
],
|
||||
"launch_types": [
|
||||
("CHAIN", "Chain Lift"),
|
||||
("LSM", "LSM Launch"),
|
||||
("HYDRAULIC", "Hydraulic Launch"),
|
||||
("GRAVITY", "Gravity"),
|
||||
("OTHER", "Other"),
|
||||
{"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": [
|
||||
{"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)"},
|
||||
@@ -823,55 +787,399 @@ class FilterOptionsAPIView(APIView):
|
||||
{"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": "-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)"},
|
||||
],
|
||||
"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",
|
||||
},
|
||||
})
|
||||
|
||||
# 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": {
|
||||
"min": ride_stats['min_height_req'] or 30,
|
||||
"max": ride_stats['max_height_req'] or 90,
|
||||
"step": 1,
|
||||
"unit": "inches"
|
||||
},
|
||||
"capacity": {
|
||||
"min": ride_stats['min_capacity'] or 0,
|
||||
"max": ride_stats['max_capacity'] or 5000,
|
||||
"step": 50,
|
||||
"unit": "riders/hour"
|
||||
},
|
||||
"ride_duration": {
|
||||
"min": ride_stats['min_duration'] or 0,
|
||||
"max": ride_stats['max_duration'] or 600,
|
||||
"step": 10,
|
||||
"unit": "seconds"
|
||||
},
|
||||
"height_ft": {
|
||||
"min": float(coaster_stats['min_height_ft'] or 0),
|
||||
"max": float(coaster_stats['max_height_ft'] or 500),
|
||||
"step": 5,
|
||||
"unit": "feet"
|
||||
},
|
||||
"length_ft": {
|
||||
"min": float(coaster_stats['min_length_ft'] or 0),
|
||||
"max": float(coaster_stats['max_length_ft'] or 10000),
|
||||
"step": 100,
|
||||
"unit": "feet"
|
||||
},
|
||||
"speed_mph": {
|
||||
"min": float(coaster_stats['min_speed_mph'] or 0),
|
||||
"max": float(coaster_stats['max_speed_mph'] or 150),
|
||||
"step": 5,
|
||||
"unit": "mph"
|
||||
},
|
||||
"inversions": {
|
||||
"min": coaster_stats['min_inversions'] or 0,
|
||||
"max": coaster_stats['max_inversions'] or 20,
|
||||
"step": 1,
|
||||
"unit": "inversions"
|
||||
},
|
||||
"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": {
|
||||
"min": ride_stats['min_year'] or 1800,
|
||||
"max": ride_stats['max_year'] or 2030,
|
||||
"step": 1,
|
||||
"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",
|
||||
},
|
||||
"opening_year": {
|
||||
"min": 1800,
|
||||
"max": 2030,
|
||||
"step": 1,
|
||||
"unit": "year",
|
||||
},
|
||||
"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_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)"},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
# --- Company search (autocomplete) -----------------------------------------
|
||||
|
||||
@@ -14,6 +14,7 @@ from drf_spectacular.utils import (
|
||||
from config.django import base as settings
|
||||
|
||||
from .shared import LocationOutputSerializer, CompanyOutputSerializer, ModelChoices
|
||||
from apps.core.services.media_url_service import MediaURLService
|
||||
|
||||
|
||||
# === PARK SERIALIZERS ===
|
||||
@@ -211,20 +212,20 @@ class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
|
||||
return [
|
||||
{
|
||||
"id": photo.id,
|
||||
"image_url": photo.image.url if photo.image else None,
|
||||
"image_variants": (
|
||||
{
|
||||
"thumbnail": (
|
||||
f"{photo.image.url}/thumbnail" if photo.image else None
|
||||
),
|
||||
"medium": f"{photo.image.url}/medium" if photo.image else None,
|
||||
"large": f"{photo.image.url}/large" if photo.image else None,
|
||||
"public": f"{photo.image.url}/public" if photo.image else None,
|
||||
}
|
||||
if photo.image
|
||||
else {}
|
||||
),
|
||||
"id": photo.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "large"),
|
||||
"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,
|
||||
"alt_text": photo.alt_text,
|
||||
"is_primary": photo.is_primary,
|
||||
@@ -244,13 +245,19 @@ class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
|
||||
if photo and photo.image:
|
||||
return {
|
||||
"id": photo.id,
|
||||
"image_url": photo.image.url,
|
||||
"id": photo.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": f"{photo.image.url}/thumbnail",
|
||||
"medium": f"{photo.image.url}/medium",
|
||||
"large": f"{photo.image.url}/large",
|
||||
"public": f"{photo.image.url}/public",
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "large"),
|
||||
"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,
|
||||
"alt_text": photo.alt_text,
|
||||
@@ -266,13 +273,19 @@ class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
# First try the explicitly set banner image
|
||||
if obj.banner_image and obj.banner_image.image:
|
||||
return {
|
||||
"id": obj.banner_image.id,
|
||||
"image_url": obj.banner_image.image.url,
|
||||
"id": obj.banner_image.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": f"{obj.banner_image.image.url}/thumbnail",
|
||||
"medium": f"{obj.banner_image.image.url}/medium",
|
||||
"large": f"{obj.banner_image.image.url}/large",
|
||||
"public": f"{obj.banner_image.image.url}/public",
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "large"),
|
||||
"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,
|
||||
"alt_text": obj.banner_image.alt_text,
|
||||
@@ -292,13 +305,19 @@ class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
|
||||
if latest_photo and latest_photo.image:
|
||||
return {
|
||||
"id": latest_photo.id,
|
||||
"image_url": latest_photo.image.url,
|
||||
"id": latest_photo.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": f"{latest_photo.image.url}/thumbnail",
|
||||
"medium": f"{latest_photo.image.url}/medium",
|
||||
"large": f"{latest_photo.image.url}/large",
|
||||
"public": f"{latest_photo.image.url}/public",
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "large"),
|
||||
"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,
|
||||
"alt_text": latest_photo.alt_text,
|
||||
@@ -315,13 +334,19 @@ class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
# First try the explicitly set card image
|
||||
if obj.card_image and obj.card_image.image:
|
||||
return {
|
||||
"id": obj.card_image.id,
|
||||
"image_url": obj.card_image.image.url,
|
||||
"id": obj.card_image.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": f"{obj.card_image.image.url}/thumbnail",
|
||||
"medium": f"{obj.card_image.image.url}/medium",
|
||||
"large": f"{obj.card_image.image.url}/large",
|
||||
"public": f"{obj.card_image.image.url}/public",
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "large"),
|
||||
"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,
|
||||
"alt_text": obj.card_image.alt_text,
|
||||
@@ -341,13 +366,19 @@ class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
|
||||
if latest_photo and latest_photo.image:
|
||||
return {
|
||||
"id": latest_photo.id,
|
||||
"image_url": latest_photo.image.url,
|
||||
"id": latest_photo.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": f"{latest_photo.image.url}/thumbnail",
|
||||
"medium": f"{latest_photo.image.url}/medium",
|
||||
"large": f"{latest_photo.image.url}/large",
|
||||
"public": f"{latest_photo.image.url}/public",
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "large"),
|
||||
"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,
|
||||
"alt_text": latest_photo.alt_text,
|
||||
|
||||
149
backend/apps/core/services/media_url_service.py
Normal file
149
backend/apps/core/services/media_url_service.py
Normal 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
|
||||
@@ -1,6 +1,9 @@
|
||||
c# Active Context
|
||||
|
||||
## 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: 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
|
||||
|
||||
@@ -287,8 +287,25 @@ The moderation system provides comprehensive content moderation, user management
|
||||
- **Returns**: Search suggestions for park names
|
||||
|
||||
### Park Details
|
||||
- **GET** `/api/v1/parks/{slug}/`
|
||||
- **Returns**: Complete park information including rides, photos, and statistics
|
||||
- **GET** `/api/v1/parks/{identifier}/`
|
||||
- **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
|
||||
- **GET** `/api/v1/parks/{park_slug}/rides/`
|
||||
|
||||
510
docs/park-detail-endpoint-documentation.md
Normal file
510
docs/park-detail-endpoint-documentation.md
Normal 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
|
||||
Reference in New Issue
Block a user