mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 07:11:08 -05:00
Implement hybrid filtering strategy for parks and rides
- Added comprehensive documentation for hybrid filtering implementation, including architecture, API endpoints, performance characteristics, and usage examples. - Developed a hybrid pagination and client-side filtering recommendation, detailing server-side responsibilities and client-side logic. - Created a test script for hybrid filtering endpoints, covering various test cases including basic filtering, search functionality, pagination, and edge cases.
This commit is contained in:
@@ -1,205 +1,633 @@
|
||||
"""
|
||||
Shared serializers and utilities for ThrillWiki API v1.
|
||||
Shared Contract Serializers for ThrillWiki API
|
||||
|
||||
This module contains common serializers and helper classes used across multiple domains
|
||||
to avoid code duplication and maintain consistency.
|
||||
This module contains standardized serializers that enforce consistent formats
|
||||
across all API responses, ensuring they match frontend TypeScript interfaces exactly.
|
||||
|
||||
These serializers prevent contract violations by providing a single source of truth
|
||||
for common data structures used throughout the API.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.conf import settings
|
||||
|
||||
# Import models inside class methods to avoid Django initialization issues
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
# Define constants to avoid import-time model loading
|
||||
CATEGORY_CHOICES = [
|
||||
("RC", "Roller Coaster"),
|
||||
("FL", "Flat Ride"),
|
||||
("DR", "Dark Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport"),
|
||||
("OT", "Other"),
|
||||
]
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
|
||||
# Placeholder for dynamic model choices - will be populated at runtime
|
||||
class ModelChoices:
|
||||
@staticmethod
|
||||
def get_ride_status_choices():
|
||||
try:
|
||||
from apps.rides.models import Ride
|
||||
|
||||
return Ride.STATUS_CHOICES
|
||||
except ImportError:
|
||||
return [("OPERATING", "Operating"), ("CLOSED", "Closed")]
|
||||
|
||||
@staticmethod
|
||||
def get_park_status_choices():
|
||||
try:
|
||||
from apps.parks.models import Park
|
||||
|
||||
return Park.STATUS_CHOICES
|
||||
except ImportError:
|
||||
return [("OPERATING", "Operating"), ("CLOSED", "Closed")]
|
||||
|
||||
@staticmethod
|
||||
def get_company_role_choices():
|
||||
try:
|
||||
from apps.parks.models import Company
|
||||
|
||||
return Company.CompanyRole.choices
|
||||
except ImportError:
|
||||
return [("OPERATOR", "Operator"), ("MANUFACTURER", "Manufacturer")]
|
||||
|
||||
@staticmethod
|
||||
def get_coaster_track_choices():
|
||||
try:
|
||||
from apps.rides.models import RollerCoasterStats
|
||||
|
||||
return RollerCoasterStats.TRACK_MATERIAL_CHOICES
|
||||
except ImportError:
|
||||
return [("STEEL", "Steel"), ("WOOD", "Wood")]
|
||||
|
||||
@staticmethod
|
||||
def get_coaster_type_choices():
|
||||
try:
|
||||
from apps.rides.models import RollerCoasterStats
|
||||
|
||||
return RollerCoasterStats.COASTER_TYPE_CHOICES
|
||||
except ImportError:
|
||||
return [("SITDOWN", "Sit Down"), ("INVERTED", "Inverted")]
|
||||
|
||||
@staticmethod
|
||||
def get_launch_choices():
|
||||
try:
|
||||
from apps.rides.models import RollerCoasterStats
|
||||
|
||||
return RollerCoasterStats.LAUNCH_CHOICES
|
||||
except ImportError:
|
||||
return [("CHAIN", "Chain Lift"), ("LAUNCH", "Launch")]
|
||||
|
||||
@staticmethod
|
||||
def get_top_list_categories():
|
||||
try:
|
||||
from apps.accounts.models import TopList
|
||||
|
||||
return TopList.Categories.choices
|
||||
except ImportError:
|
||||
return [("RC", "Roller Coasters"), ("PARKS", "Parks")]
|
||||
|
||||
@staticmethod
|
||||
def get_ride_post_closing_choices():
|
||||
try:
|
||||
from apps.rides.models import Ride
|
||||
|
||||
return Ride.POST_CLOSING_STATUS_CHOICES
|
||||
except ImportError:
|
||||
return [
|
||||
("DEMOLISHED", "Demolished"),
|
||||
("RELOCATED", "Relocated"),
|
||||
("SBNO", "Standing But Not Operating"),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_ride_category_choices():
|
||||
try:
|
||||
from apps.rides.models import CATEGORY_CHOICES
|
||||
|
||||
return CATEGORY_CHOICES
|
||||
except ImportError:
|
||||
return [
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport"),
|
||||
("OT", "Other"),
|
||||
]
|
||||
class FilterOptionSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard filter option format - matches frontend TypeScript exactly.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
selected?: boolean;
|
||||
}
|
||||
"""
|
||||
value = serializers.CharField(
|
||||
help_text="The actual value used for filtering"
|
||||
)
|
||||
label = serializers.CharField(
|
||||
help_text="Human-readable display label"
|
||||
)
|
||||
count = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Number of items matching this filter option"
|
||||
)
|
||||
selected = serializers.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this option is currently selected"
|
||||
)
|
||||
|
||||
|
||||
class LocationOutputSerializer(serializers.Serializer):
|
||||
"""Shared serializer for location data."""
|
||||
class FilterRangeSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard range filter format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface FilterRange {
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
unit?: string;
|
||||
}
|
||||
"""
|
||||
min = serializers.FloatField(
|
||||
allow_null=True,
|
||||
help_text="Minimum value for the range"
|
||||
)
|
||||
max = serializers.FloatField(
|
||||
allow_null=True,
|
||||
help_text="Maximum value for the range"
|
||||
)
|
||||
step = serializers.FloatField(
|
||||
default=1.0,
|
||||
help_text="Step size for range inputs"
|
||||
)
|
||||
unit = serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Unit of measurement (e.g., 'feet', 'mph', 'stars')"
|
||||
)
|
||||
|
||||
latitude = serializers.SerializerMethodField()
|
||||
longitude = serializers.SerializerMethodField()
|
||||
city = serializers.SerializerMethodField()
|
||||
state = serializers.SerializerMethodField()
|
||||
country = serializers.SerializerMethodField()
|
||||
formatted_address = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||
def get_latitude(self, obj) -> float | None:
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return obj.location.latitude
|
||||
return None
|
||||
class BooleanFilterSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard boolean filter format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface BooleanFilter {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
"""
|
||||
key = serializers.CharField(
|
||||
help_text="The filter parameter key"
|
||||
)
|
||||
label = serializers.CharField(
|
||||
help_text="Human-readable label for the filter"
|
||||
)
|
||||
description = serializers.CharField(
|
||||
help_text="Description of what this filter does"
|
||||
)
|
||||
|
||||
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||
def get_longitude(self, obj) -> float | None:
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return obj.location.longitude
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_city(self, obj) -> str | None:
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return obj.location.city
|
||||
return None
|
||||
class OrderingOptionSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard ordering option format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface OrderingOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
"""
|
||||
value = serializers.CharField(
|
||||
help_text="The ordering parameter value"
|
||||
)
|
||||
label = serializers.CharField(
|
||||
help_text="Human-readable label for the ordering option"
|
||||
)
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_state(self, obj) -> str | None:
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return obj.location.state
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_country(self, obj) -> str | None:
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return obj.location.country
|
||||
return None
|
||||
class StandardizedFilterMetadataSerializer(serializers.Serializer):
|
||||
"""
|
||||
Matches frontend TypeScript interface exactly.
|
||||
|
||||
This serializer ensures all filter metadata responses follow the same structure
|
||||
that the frontend expects, preventing runtime type errors.
|
||||
"""
|
||||
categorical = serializers.DictField(
|
||||
child=FilterOptionSerializer(many=True),
|
||||
help_text="Categorical filter options with value/label/count structure"
|
||||
)
|
||||
ranges = serializers.DictField(
|
||||
child=FilterRangeSerializer(),
|
||||
help_text="Range filter metadata with min/max/step/unit"
|
||||
)
|
||||
total_count = serializers.IntegerField(
|
||||
help_text="Total number of items in the filtered dataset"
|
||||
)
|
||||
ordering_options = FilterOptionSerializer(
|
||||
many=True,
|
||||
required=False,
|
||||
help_text="Available ordering options"
|
||||
)
|
||||
boolean_filters = BooleanFilterSerializer(
|
||||
many=True,
|
||||
required=False,
|
||||
help_text="Available boolean filter options"
|
||||
)
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_formatted_address(self, obj) -> str:
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return obj.location.formatted_address
|
||||
return ""
|
||||
|
||||
class PaginationMetadataSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard pagination metadata format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface PaginationMetadata {
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
"""
|
||||
count = serializers.IntegerField(
|
||||
help_text="Total number of items across all pages"
|
||||
)
|
||||
next = serializers.URLField(
|
||||
allow_null=True,
|
||||
required=False,
|
||||
help_text="URL for the next page of results"
|
||||
)
|
||||
previous = serializers.URLField(
|
||||
allow_null=True,
|
||||
required=False,
|
||||
help_text="URL for the previous page of results"
|
||||
)
|
||||
page_size = serializers.IntegerField(
|
||||
help_text="Number of items per page"
|
||||
)
|
||||
current_page = serializers.IntegerField(
|
||||
help_text="Current page number (1-indexed)"
|
||||
)
|
||||
total_pages = serializers.IntegerField(
|
||||
help_text="Total number of pages"
|
||||
)
|
||||
|
||||
|
||||
class ApiResponseSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard API response wrapper.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
errors?: ValidationError;
|
||||
}
|
||||
"""
|
||||
success = serializers.BooleanField(
|
||||
help_text="Whether the request was successful"
|
||||
)
|
||||
response_data = serializers.JSONField(
|
||||
required=False,
|
||||
help_text="Response data (structure varies by endpoint)",
|
||||
source='data'
|
||||
)
|
||||
message = serializers.CharField(
|
||||
required=False,
|
||||
help_text="Human-readable message about the operation"
|
||||
)
|
||||
response_errors = serializers.DictField(
|
||||
required=False,
|
||||
help_text="Validation errors (field -> error messages)",
|
||||
source='errors'
|
||||
)
|
||||
|
||||
|
||||
class ErrorResponseSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard error response format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface ApiError {
|
||||
status: "error";
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
request_user?: string;
|
||||
};
|
||||
data: null;
|
||||
}
|
||||
"""
|
||||
status = serializers.CharField(
|
||||
default="error",
|
||||
help_text="Response status indicator"
|
||||
)
|
||||
error = serializers.DictField(
|
||||
help_text="Error details"
|
||||
)
|
||||
response_data = serializers.JSONField(
|
||||
default=None,
|
||||
allow_null=True,
|
||||
help_text="Always null for error responses",
|
||||
source='data'
|
||||
)
|
||||
|
||||
|
||||
class LocationSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard location format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface Location {
|
||||
city: string;
|
||||
state?: string;
|
||||
country: string;
|
||||
address?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}
|
||||
"""
|
||||
city = serializers.CharField(
|
||||
help_text="City name"
|
||||
)
|
||||
state = serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="State/province name"
|
||||
)
|
||||
country = serializers.CharField(
|
||||
help_text="Country name"
|
||||
)
|
||||
address = serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Street address"
|
||||
)
|
||||
latitude = serializers.FloatField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Latitude coordinate"
|
||||
)
|
||||
longitude = serializers.FloatField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Longitude coordinate"
|
||||
)
|
||||
|
||||
|
||||
# Alias for backward compatibility
|
||||
LocationOutputSerializer = LocationSerializer
|
||||
|
||||
|
||||
class CompanyOutputSerializer(serializers.Serializer):
|
||||
"""Shared serializer for company data."""
|
||||
"""
|
||||
Standard company output format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface Company {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
roles?: string[];
|
||||
}
|
||||
"""
|
||||
id = serializers.IntegerField(
|
||||
help_text="Company ID"
|
||||
)
|
||||
name = serializers.CharField(
|
||||
help_text="Company name"
|
||||
)
|
||||
slug = serializers.SlugField(
|
||||
help_text="URL-friendly identifier"
|
||||
)
|
||||
roles = serializers.ListField(
|
||||
child=serializers.CharField(),
|
||||
required=False,
|
||||
help_text="Company roles (manufacturer, operator, etc.)"
|
||||
)
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
roles = serializers.ListField(child=serializers.CharField(), required=False)
|
||||
url = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.URLField())
|
||||
def get_url(self, obj) -> str:
|
||||
"""Generate the frontend URL for this company based on their primary role.
|
||||
# Category choices for ride models
|
||||
CATEGORY_CHOICES = [
|
||||
('RC', 'Roller Coaster'),
|
||||
('DR', 'Dark Ride'),
|
||||
('FR', 'Flat Ride'),
|
||||
('WR', 'Water Ride'),
|
||||
('TR', 'Transport Ride'),
|
||||
]
|
||||
|
||||
CRITICAL DOMAIN SEPARATION:
|
||||
- OPERATOR and PROPERTY_OWNER are for parks domain
|
||||
- MANUFACTURER and DESIGNER are for rides domain
|
||||
"""
|
||||
# Use the URL field from the model if it exists (auto-generated on save)
|
||||
if hasattr(obj, "url") and obj.url:
|
||||
return obj.url
|
||||
|
||||
# Fallback URL generation (should not be needed if model save works correctly)
|
||||
if hasattr(obj, "roles") and obj.roles:
|
||||
frontend_domain = getattr(
|
||||
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
|
||||
)
|
||||
primary_role = obj.roles[0] if obj.roles else None
|
||||
class ModelChoices:
|
||||
"""
|
||||
Utility class to provide model choices for serializers.
|
||||
This prevents circular imports while providing access to model choices.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_park_status_choices():
|
||||
"""Get park status choices."""
|
||||
return [
|
||||
('OPERATING', 'Operating'),
|
||||
('CLOSED_TEMP', 'Temporarily Closed'),
|
||||
('CLOSED_PERM', 'Permanently Closed'),
|
||||
('UNDER_CONSTRUCTION', 'Under Construction'),
|
||||
('PLANNED', 'Planned'),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_ride_status_choices():
|
||||
"""Get ride status choices."""
|
||||
return [
|
||||
('OPERATING', 'Operating'),
|
||||
('CLOSED_TEMP', 'Temporarily Closed'),
|
||||
('CLOSED_PERM', 'Permanently Closed'),
|
||||
('SBNO', 'Standing But Not Operating'),
|
||||
('UNDER_CONSTRUCTION', 'Under Construction'),
|
||||
('RELOCATED', 'Relocated'),
|
||||
('DEMOLISHED', 'Demolished'),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_company_role_choices():
|
||||
"""Get company role choices."""
|
||||
return [
|
||||
('MANUFACTURER', 'Manufacturer'),
|
||||
('OPERATOR', 'Operator'),
|
||||
('DESIGNER', 'Designer'),
|
||||
('PROPERTY_OWNER', 'Property Owner'),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_ride_category_choices():
|
||||
"""Get ride category choices."""
|
||||
return CATEGORY_CHOICES
|
||||
|
||||
@staticmethod
|
||||
def get_ride_post_closing_choices():
|
||||
"""Get ride post-closing status choices."""
|
||||
return [
|
||||
('RELOCATED', 'Relocated'),
|
||||
('DEMOLISHED', 'Demolished'),
|
||||
('STORED', 'Stored'),
|
||||
('UNKNOWN', 'Unknown'),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_coaster_track_choices():
|
||||
"""Get coaster track type choices."""
|
||||
return [
|
||||
('STEEL', 'Steel'),
|
||||
('WOOD', 'Wood'),
|
||||
('HYBRID', 'Hybrid'),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_coaster_type_choices():
|
||||
"""Get coaster type choices."""
|
||||
return [
|
||||
('SIT_DOWN', 'Sit Down'),
|
||||
('INVERTED', 'Inverted'),
|
||||
('FLOORLESS', 'Floorless'),
|
||||
('FLYING', 'Flying'),
|
||||
('STAND_UP', 'Stand Up'),
|
||||
('SPINNING', 'Spinning'),
|
||||
('WING', 'Wing'),
|
||||
('DIVE', 'Dive'),
|
||||
('LAUNCHED', 'Launched'),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_launch_choices():
|
||||
"""Get launch system choices."""
|
||||
return [
|
||||
('NONE', 'None'),
|
||||
('LIM', 'Linear Induction Motor'),
|
||||
('LSM', 'Linear Synchronous Motor'),
|
||||
('HYDRAULIC', 'Hydraulic'),
|
||||
('PNEUMATIC', 'Pneumatic'),
|
||||
('CABLE', 'Cable'),
|
||||
('FLYWHEEL', 'Flywheel'),
|
||||
]
|
||||
|
||||
# Only generate URLs for rides domain roles here
|
||||
if primary_role == "MANUFACTURER":
|
||||
return f"{frontend_domain}/rides/manufacturers/{obj.slug}/"
|
||||
elif primary_role == "DESIGNER":
|
||||
return f"{frontend_domain}/rides/designers/{obj.slug}/"
|
||||
# OPERATOR and PROPERTY_OWNER URLs are handled by parks domain
|
||||
|
||||
return ""
|
||||
class EntityReferenceSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard entity reference format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface Entity {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
"""
|
||||
id = serializers.IntegerField(
|
||||
help_text="Unique identifier"
|
||||
)
|
||||
name = serializers.CharField(
|
||||
help_text="Display name"
|
||||
)
|
||||
slug = serializers.SlugField(
|
||||
help_text="URL-friendly identifier"
|
||||
)
|
||||
|
||||
|
||||
class ImageVariantsSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard image variants format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface ImageVariants {
|
||||
thumbnail: string;
|
||||
medium: string;
|
||||
large: string;
|
||||
avatar?: string;
|
||||
}
|
||||
"""
|
||||
thumbnail = serializers.URLField(
|
||||
help_text="Thumbnail size image URL"
|
||||
)
|
||||
medium = serializers.URLField(
|
||||
help_text="Medium size image URL"
|
||||
)
|
||||
large = serializers.URLField(
|
||||
help_text="Large size image URL"
|
||||
)
|
||||
avatar = serializers.URLField(
|
||||
required=False,
|
||||
help_text="Avatar size image URL (for user avatars)"
|
||||
)
|
||||
|
||||
|
||||
class PhotoSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard photo format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface Photo {
|
||||
id: number;
|
||||
image_variants: ImageVariants;
|
||||
alt_text?: string;
|
||||
image_url?: string;
|
||||
caption?: string;
|
||||
photo_type?: string;
|
||||
uploaded_by?: UserInfo;
|
||||
uploaded_at?: string;
|
||||
}
|
||||
"""
|
||||
id = serializers.IntegerField(
|
||||
help_text="Photo ID"
|
||||
)
|
||||
image_variants = ImageVariantsSerializer(
|
||||
help_text="Available image size variants"
|
||||
)
|
||||
alt_text = serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Alternative text for accessibility"
|
||||
)
|
||||
image_url = serializers.URLField(
|
||||
required=False,
|
||||
help_text="Primary image URL (for compatibility)"
|
||||
)
|
||||
caption = serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Photo caption"
|
||||
)
|
||||
photo_type = serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Type/category of photo"
|
||||
)
|
||||
uploaded_by = EntityReferenceSerializer(
|
||||
required=False,
|
||||
help_text="User who uploaded the photo"
|
||||
)
|
||||
uploaded_at = serializers.DateTimeField(
|
||||
required=False,
|
||||
help_text="When the photo was uploaded"
|
||||
)
|
||||
|
||||
|
||||
class UserInfoSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard user info format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface UserInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
"""
|
||||
id = serializers.IntegerField(
|
||||
help_text="User ID"
|
||||
)
|
||||
username = serializers.CharField(
|
||||
help_text="Username"
|
||||
)
|
||||
display_name = serializers.CharField(
|
||||
help_text="Display name"
|
||||
)
|
||||
avatar_url = serializers.URLField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="User avatar URL"
|
||||
)
|
||||
|
||||
|
||||
def validate_filter_metadata_contract(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate that filter metadata follows the expected contract.
|
||||
|
||||
This function can be used in views to ensure filter metadata
|
||||
matches the frontend TypeScript interface before returning it.
|
||||
|
||||
Args:
|
||||
data: Filter metadata dictionary
|
||||
|
||||
Returns:
|
||||
Validated and potentially transformed data
|
||||
|
||||
Raises:
|
||||
serializers.ValidationError: If data doesn't match contract
|
||||
"""
|
||||
serializer = StandardizedFilterMetadataSerializer(data=data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
# Return validated_data directly - it's already a dict
|
||||
return serializer.validated_data
|
||||
|
||||
|
||||
def ensure_filter_option_format(options: List[Any]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Ensure a list of filter options follows the expected format.
|
||||
|
||||
This utility function converts various input formats to the standard
|
||||
FilterOption format expected by the frontend.
|
||||
|
||||
Args:
|
||||
options: List of options in various formats
|
||||
|
||||
Returns:
|
||||
List of options in standard format
|
||||
"""
|
||||
standardized = []
|
||||
|
||||
for option in options:
|
||||
if isinstance(option, dict):
|
||||
# Already in correct format or close to it
|
||||
standardized_option = {
|
||||
'value': str(option.get('value', option.get('id', ''))),
|
||||
'label': option.get('label', option.get('name', str(option.get('value', '')))),
|
||||
'count': option.get('count'),
|
||||
'selected': option.get('selected', False)
|
||||
}
|
||||
elif isinstance(option, (list, tuple)) and len(option) >= 2:
|
||||
# Tuple format: (value, label) or (value, label, count)
|
||||
standardized_option = {
|
||||
'value': str(option[0]),
|
||||
'label': str(option[1]),
|
||||
'count': option[2] if len(option) > 2 else None,
|
||||
'selected': False
|
||||
}
|
||||
else:
|
||||
# Simple value - use as both value and label
|
||||
standardized_option = {
|
||||
'value': str(option),
|
||||
'label': str(option),
|
||||
'count': None,
|
||||
'selected': False
|
||||
}
|
||||
|
||||
standardized.append(standardized_option)
|
||||
|
||||
return standardized
|
||||
|
||||
|
||||
def ensure_range_format(range_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Ensure range data follows the expected format.
|
||||
|
||||
Args:
|
||||
range_data: Range data dictionary
|
||||
|
||||
Returns:
|
||||
Range data in standard format
|
||||
"""
|
||||
return {
|
||||
'min': range_data.get('min'),
|
||||
'max': range_data.get('max'),
|
||||
'step': range_data.get('step', 1.0),
|
||||
'unit': range_data.get('unit')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user