Files
thrilltrack-explorer/django/api/v1/endpoints/search.py
pacnpal d6ff4cc3a3 Add email templates for user notifications and account management
- Created a base email template (base.html) for consistent styling across all emails.
- Added moderation approval email template (moderation_approved.html) to notify users of approved submissions.
- Added moderation rejection email template (moderation_rejected.html) to inform users of required changes for their submissions.
- Created password reset email template (password_reset.html) for users requesting to reset their passwords.
- Developed a welcome email template (welcome.html) to greet new users and provide account details and tips for using ThrillWiki.
2025-11-08 15:34:04 -05:00

439 lines
15 KiB
Python

"""
Search and autocomplete endpoints for ThrillWiki API.
Provides full-text search and filtering across all entity types.
"""
from typing import List, Optional
from uuid import UUID
from datetime import date
from decimal import Decimal
from django.http import HttpRequest
from ninja import Router, Query
from apps.entities.search import SearchService
from apps.users.permissions import jwt_auth
from api.v1.schemas import (
GlobalSearchResponse,
CompanySearchResult,
RideModelSearchResult,
ParkSearchResult,
RideSearchResult,
AutocompleteResponse,
AutocompleteItem,
ErrorResponse,
)
router = Router(tags=["Search"])
search_service = SearchService()
# ============================================================================
# Helper Functions
# ============================================================================
def _company_to_search_result(company) -> CompanySearchResult:
"""Convert Company model to search result."""
return CompanySearchResult(
id=company.id,
name=company.name,
slug=company.slug,
entity_type='company',
description=company.description,
image_url=company.logo_image_url or None,
company_types=company.company_types or [],
park_count=company.park_count,
ride_count=company.ride_count,
)
def _ride_model_to_search_result(model) -> RideModelSearchResult:
"""Convert RideModel to search result."""
return RideModelSearchResult(
id=model.id,
name=model.name,
slug=model.slug,
entity_type='ride_model',
description=model.description,
image_url=model.image_url or None,
manufacturer_name=model.manufacturer.name if model.manufacturer else '',
model_type=model.model_type,
installation_count=model.installation_count,
)
def _park_to_search_result(park) -> ParkSearchResult:
"""Convert Park model to search result."""
return ParkSearchResult(
id=park.id,
name=park.name,
slug=park.slug,
entity_type='park',
description=park.description,
image_url=park.banner_image_url or park.logo_image_url or None,
park_type=park.park_type,
status=park.status,
operator_name=park.operator.name if park.operator else None,
ride_count=park.ride_count,
coaster_count=park.coaster_count,
coordinates=park.coordinates,
)
def _ride_to_search_result(ride) -> RideSearchResult:
"""Convert Ride model to search result."""
return RideSearchResult(
id=ride.id,
name=ride.name,
slug=ride.slug,
entity_type='ride',
description=ride.description,
image_url=ride.image_url or None,
park_name=ride.park.name if ride.park else '',
park_slug=ride.park.slug if ride.park else '',
manufacturer_name=ride.manufacturer.name if ride.manufacturer else None,
ride_category=ride.ride_category,
status=ride.status,
is_coaster=ride.is_coaster,
)
# ============================================================================
# Search Endpoints
# ============================================================================
@router.get(
"",
response={200: GlobalSearchResponse, 400: ErrorResponse},
summary="Global search across all entities"
)
def search_all(
request: HttpRequest,
q: str = Query(..., min_length=2, max_length=200, description="Search query"),
entity_types: Optional[List[str]] = Query(None, description="Filter by entity types (company, ride_model, park, ride)"),
limit: int = Query(20, ge=1, le=100, description="Maximum results per entity type"),
):
"""
Search across all entity types with full-text search.
- **q**: Search query (minimum 2 characters)
- **entity_types**: Optional list of entity types to search (defaults to all)
- **limit**: Maximum results per entity type (1-100, default 20)
Returns results grouped by entity type.
"""
try:
results = search_service.search_all(
query=q,
entity_types=entity_types,
limit=limit
)
# Convert to schema objects
response_data = {
'query': q,
'total_results': 0,
'companies': [],
'ride_models': [],
'parks': [],
'rides': [],
}
if 'companies' in results:
response_data['companies'] = [
_company_to_search_result(c) for c in results['companies']
]
response_data['total_results'] += len(response_data['companies'])
if 'ride_models' in results:
response_data['ride_models'] = [
_ride_model_to_search_result(m) for m in results['ride_models']
]
response_data['total_results'] += len(response_data['ride_models'])
if 'parks' in results:
response_data['parks'] = [
_park_to_search_result(p) for p in results['parks']
]
response_data['total_results'] += len(response_data['parks'])
if 'rides' in results:
response_data['rides'] = [
_ride_to_search_result(r) for r in results['rides']
]
response_data['total_results'] += len(response_data['rides'])
return GlobalSearchResponse(**response_data)
except Exception as e:
return 400, ErrorResponse(detail=str(e))
@router.get(
"/companies",
response={200: List[CompanySearchResult], 400: ErrorResponse},
summary="Search companies"
)
def search_companies(
request: HttpRequest,
q: str = Query(..., min_length=2, max_length=200, description="Search query"),
company_types: Optional[List[str]] = Query(None, description="Filter by company types"),
founded_after: Optional[date] = Query(None, description="Founded after date"),
founded_before: Optional[date] = Query(None, description="Founded before date"),
limit: int = Query(20, ge=1, le=100, description="Maximum results"),
):
"""
Search companies with optional filters.
- **q**: Search query
- **company_types**: Filter by types (manufacturer, operator, designer, etc.)
- **founded_after/before**: Filter by founding date range
- **limit**: Maximum results (1-100, default 20)
"""
try:
filters = {}
if company_types:
filters['company_types'] = company_types
if founded_after:
filters['founded_after'] = founded_after
if founded_before:
filters['founded_before'] = founded_before
results = search_service.search_companies(
query=q,
filters=filters if filters else None,
limit=limit
)
return [_company_to_search_result(c) for c in results]
except Exception as e:
return 400, ErrorResponse(detail=str(e))
@router.get(
"/ride-models",
response={200: List[RideModelSearchResult], 400: ErrorResponse},
summary="Search ride models"
)
def search_ride_models(
request: HttpRequest,
q: str = Query(..., min_length=2, max_length=200, description="Search query"),
manufacturer_id: Optional[UUID] = Query(None, description="Filter by manufacturer"),
model_type: Optional[str] = Query(None, description="Filter by model type"),
limit: int = Query(20, ge=1, le=100, description="Maximum results"),
):
"""
Search ride models with optional filters.
- **q**: Search query
- **manufacturer_id**: Filter by specific manufacturer
- **model_type**: Filter by model type
- **limit**: Maximum results (1-100, default 20)
"""
try:
filters = {}
if manufacturer_id:
filters['manufacturer_id'] = manufacturer_id
if model_type:
filters['model_type'] = model_type
results = search_service.search_ride_models(
query=q,
filters=filters if filters else None,
limit=limit
)
return [_ride_model_to_search_result(m) for m in results]
except Exception as e:
return 400, ErrorResponse(detail=str(e))
@router.get(
"/parks",
response={200: List[ParkSearchResult], 400: ErrorResponse},
summary="Search parks"
)
def search_parks(
request: HttpRequest,
q: str = Query(..., min_length=2, max_length=200, description="Search query"),
status: Optional[str] = Query(None, description="Filter by status"),
park_type: Optional[str] = Query(None, description="Filter by park type"),
operator_id: Optional[UUID] = Query(None, description="Filter by operator"),
opening_after: Optional[date] = Query(None, description="Opened after date"),
opening_before: Optional[date] = Query(None, description="Opened before date"),
latitude: Optional[float] = Query(None, description="Search center latitude"),
longitude: Optional[float] = Query(None, description="Search center longitude"),
radius: Optional[float] = Query(None, ge=0, le=500, description="Search radius in km (PostGIS only)"),
limit: int = Query(20, ge=1, le=100, description="Maximum results"),
):
"""
Search parks with optional filters including location-based search.
- **q**: Search query
- **status**: Filter by operational status
- **park_type**: Filter by park type
- **operator_id**: Filter by operator company
- **opening_after/before**: Filter by opening date range
- **latitude/longitude/radius**: Location-based filtering (PostGIS only)
- **limit**: Maximum results (1-100, default 20)
"""
try:
filters = {}
if status:
filters['status'] = status
if park_type:
filters['park_type'] = park_type
if operator_id:
filters['operator_id'] = operator_id
if opening_after:
filters['opening_after'] = opening_after
if opening_before:
filters['opening_before'] = opening_before
# Location-based search (PostGIS only)
if latitude is not None and longitude is not None and radius is not None:
filters['location'] = (longitude, latitude)
filters['radius'] = radius
results = search_service.search_parks(
query=q,
filters=filters if filters else None,
limit=limit
)
return [_park_to_search_result(p) for p in results]
except Exception as e:
return 400, ErrorResponse(detail=str(e))
@router.get(
"/rides",
response={200: List[RideSearchResult], 400: ErrorResponse},
summary="Search rides"
)
def search_rides(
request: HttpRequest,
q: str = Query(..., min_length=2, max_length=200, description="Search query"),
park_id: Optional[UUID] = Query(None, description="Filter by park"),
manufacturer_id: Optional[UUID] = Query(None, description="Filter by manufacturer"),
model_id: Optional[UUID] = Query(None, description="Filter by model"),
status: Optional[str] = Query(None, description="Filter by status"),
ride_category: Optional[str] = Query(None, description="Filter by category"),
is_coaster: Optional[bool] = Query(None, description="Filter coasters only"),
opening_after: Optional[date] = Query(None, description="Opened after date"),
opening_before: Optional[date] = Query(None, description="Opened before date"),
min_height: Optional[Decimal] = Query(None, description="Minimum height in feet"),
max_height: Optional[Decimal] = Query(None, description="Maximum height in feet"),
min_speed: Optional[Decimal] = Query(None, description="Minimum speed in mph"),
max_speed: Optional[Decimal] = Query(None, description="Maximum speed in mph"),
limit: int = Query(20, ge=1, le=100, description="Maximum results"),
):
"""
Search rides with extensive filtering options.
- **q**: Search query
- **park_id**: Filter by specific park
- **manufacturer_id**: Filter by manufacturer
- **model_id**: Filter by specific ride model
- **status**: Filter by operational status
- **ride_category**: Filter by category (roller_coaster, flat_ride, etc.)
- **is_coaster**: Filter to show only coasters
- **opening_after/before**: Filter by opening date range
- **min_height/max_height**: Filter by height range (feet)
- **min_speed/max_speed**: Filter by speed range (mph)
- **limit**: Maximum results (1-100, default 20)
"""
try:
filters = {}
if park_id:
filters['park_id'] = park_id
if manufacturer_id:
filters['manufacturer_id'] = manufacturer_id
if model_id:
filters['model_id'] = model_id
if status:
filters['status'] = status
if ride_category:
filters['ride_category'] = ride_category
if is_coaster is not None:
filters['is_coaster'] = is_coaster
if opening_after:
filters['opening_after'] = opening_after
if opening_before:
filters['opening_before'] = opening_before
if min_height:
filters['min_height'] = min_height
if max_height:
filters['max_height'] = max_height
if min_speed:
filters['min_speed'] = min_speed
if max_speed:
filters['max_speed'] = max_speed
results = search_service.search_rides(
query=q,
filters=filters if filters else None,
limit=limit
)
return [_ride_to_search_result(r) for r in results]
except Exception as e:
return 400, ErrorResponse(detail=str(e))
# ============================================================================
# Autocomplete Endpoint
# ============================================================================
@router.get(
"/autocomplete",
response={200: AutocompleteResponse, 400: ErrorResponse},
summary="Autocomplete suggestions"
)
def autocomplete(
request: HttpRequest,
q: str = Query(..., min_length=2, max_length=100, description="Partial search query"),
entity_type: Optional[str] = Query(None, description="Filter by entity type (company, park, ride, ride_model)"),
limit: int = Query(10, ge=1, le=20, description="Maximum suggestions"),
):
"""
Get autocomplete suggestions for search.
- **q**: Partial query (minimum 2 characters)
- **entity_type**: Optional entity type filter
- **limit**: Maximum suggestions (1-20, default 10)
Returns quick name-based suggestions for autocomplete UIs.
"""
try:
suggestions = search_service.autocomplete(
query=q,
entity_type=entity_type,
limit=limit
)
# Convert to schema objects
items = [
AutocompleteItem(
id=s['id'],
name=s['name'],
slug=s['slug'],
entity_type=s['entity_type'],
park_name=s.get('park_name'),
manufacturer_name=s.get('manufacturer_name'),
)
for s in suggestions
]
return AutocompleteResponse(
query=q,
suggestions=items
)
except Exception as e:
return 400, ErrorResponse(detail=str(e))