Refactor API structure and add comprehensive user management features

- Restructure API v1 with improved serializers organization
- Add user deletion requests and moderation queue system
- Implement bulk moderation operations and permissions
- Add user profile enhancements with display names and avatars
- Expand ride and park API endpoints with better filtering
- Add manufacturer API with detailed ride relationships
- Improve authentication flows and error handling
- Update frontend documentation and API specifications
This commit is contained in:
pacnpal
2025-08-29 16:03:51 -04:00
parent 7b9f64be72
commit bb7da85516
92 changed files with 19690 additions and 9076 deletions

View File

@@ -29,37 +29,51 @@ app_name = "api_v1_ride_models"
urlpatterns = [
# Core ride model endpoints - nested under manufacturer
path("", RideModelListCreateAPIView.as_view(), name="ride-model-list-create"),
path("<slug:ride_model_slug>/", RideModelDetailAPIView.as_view(), name="ride-model-detail"),
path(
"<slug:ride_model_slug>/",
RideModelDetailAPIView.as_view(),
name="ride-model-detail",
),
# Search and filtering (global, not manufacturer-specific)
path("search/", RideModelSearchAPIView.as_view(), name="ride-model-search"),
path("filter-options/", RideModelFilterOptionsAPIView.as_view(),
name="ride-model-filter-options"),
path(
"filter-options/",
RideModelFilterOptionsAPIView.as_view(),
name="ride-model-filter-options",
),
# Statistics (global, not manufacturer-specific)
path("stats/", RideModelStatsAPIView.as_view(), name="ride-model-stats"),
# Ride model variants - using slug-based lookup
path("<slug:ride_model_slug>/variants/",
RideModelVariantListCreateAPIView.as_view(),
name="ride-model-variant-list-create"),
path("<slug:ride_model_slug>/variants/<int:pk>/",
RideModelVariantDetailAPIView.as_view(),
name="ride-model-variant-detail"),
path(
"<slug:ride_model_slug>/variants/",
RideModelVariantListCreateAPIView.as_view(),
name="ride-model-variant-list-create",
),
path(
"<slug:ride_model_slug>/variants/<int:pk>/",
RideModelVariantDetailAPIView.as_view(),
name="ride-model-variant-detail",
),
# Technical specifications - using slug-based lookup
path("<slug:ride_model_slug>/technical-specs/",
RideModelTechnicalSpecListCreateAPIView.as_view(),
name="ride-model-technical-spec-list-create"),
path("<slug:ride_model_slug>/technical-specs/<int:pk>/",
RideModelTechnicalSpecDetailAPIView.as_view(),
name="ride-model-technical-spec-detail"),
path(
"<slug:ride_model_slug>/technical-specs/",
RideModelTechnicalSpecListCreateAPIView.as_view(),
name="ride-model-technical-spec-list-create",
),
path(
"<slug:ride_model_slug>/technical-specs/<int:pk>/",
RideModelTechnicalSpecDetailAPIView.as_view(),
name="ride-model-technical-spec-detail",
),
# Photos - using slug-based lookup
path("<slug:ride_model_slug>/photos/",
RideModelPhotoListCreateAPIView.as_view(),
name="ride-model-photo-list-create"),
path("<slug:ride_model_slug>/photos/<int:pk>/",
RideModelPhotoDetailAPIView.as_view(),
name="ride-model-photo-detail"),
path(
"<slug:ride_model_slug>/photos/",
RideModelPhotoListCreateAPIView.as_view(),
name="ride-model-photo-list-create",
),
path(
"<slug:ride_model_slug>/photos/<int:pk>/",
RideModelPhotoDetailAPIView.as_view(),
name="ride-model-photo-detail",
),
]

View File

@@ -13,7 +13,7 @@ This module implements comprehensive endpoints for ride model management:
"""
from typing import Any
from datetime import datetime, timedelta
from datetime import timedelta
from rest_framework import status, permissions
from rest_framework.views import APIView
@@ -36,25 +36,31 @@ from apps.api.v1.serializers.ride_models import (
RideModelVariantOutputSerializer,
RideModelVariantCreateInputSerializer,
RideModelVariantUpdateInputSerializer,
RideModelTechnicalSpecOutputSerializer,
RideModelTechnicalSpecCreateInputSerializer,
RideModelTechnicalSpecUpdateInputSerializer,
RideModelPhotoOutputSerializer,
RideModelPhotoCreateInputSerializer,
RideModelPhotoUpdateInputSerializer,
RideModelStatsOutputSerializer,
)
# Attempt to import models; fall back gracefully if not present
try:
from apps.rides.models import RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec
from apps.rides.models import (
RideModel,
RideModelVariant,
RideModelPhoto,
RideModelTechnicalSpec,
)
from apps.rides.models.company import Company
MODELS_AVAILABLE = True
except ImportError:
try:
# Try alternative import path
from apps.rides.models.rides import RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec
from apps.rides.models.rides import (
RideModel,
RideModelVariant,
RideModelPhoto,
RideModelTechnicalSpec,
)
from apps.rides.models.rides import Company
MODELS_AVAILABLE = True
except ImportError:
RideModel = None
@@ -82,7 +88,10 @@ class RideModelListCreateAPIView(APIView):
description="List ride models with comprehensive filtering and pagination.",
parameters=[
OpenApiParameter(
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
name="manufacturer_slug",
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
required=True,
),
OpenApiParameter(
name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
@@ -97,10 +106,14 @@ class RideModelListCreateAPIView(APIView):
name="category", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
),
OpenApiParameter(
name="target_market", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
name="target_market",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
),
OpenApiParameter(
name="is_discontinued", location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL
name="is_discontinued",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.BOOL,
),
],
responses={200: RideModelListOutputSerializer(many=True)},
@@ -123,7 +136,11 @@ class RideModelListCreateAPIView(APIView):
except Company.DoesNotExist:
raise NotFound("Manufacturer not found")
qs = RideModel.objects.filter(manufacturer=manufacturer).select_related("manufacturer").prefetch_related("photos")
qs = (
RideModel.objects.filter(manufacturer=manufacturer)
.select_related("manufacturer")
.prefetch_related("photos")
)
# Apply filters
filter_serializer = RideModelFilterInputSerializer(data=request.query_params)
@@ -134,9 +151,9 @@ class RideModelListCreateAPIView(APIView):
if filters.get("search"):
search_term = filters["search"]
qs = qs.filter(
Q(name__icontains=search_term) |
Q(description__icontains=search_term) |
Q(manufacturer__name__icontains=search_term)
Q(name__icontains=search_term)
| Q(description__icontains=search_term)
| Q(manufacturer__name__icontains=search_term)
)
# Category filter
@@ -160,10 +177,12 @@ class RideModelListCreateAPIView(APIView):
# Year filters
if filters.get("first_installation_year_min"):
qs = qs.filter(
first_installation_year__gte=filters["first_installation_year_min"])
first_installation_year__gte=filters["first_installation_year_min"]
)
if filters.get("first_installation_year_max"):
qs = qs.filter(
first_installation_year__lte=filters["first_installation_year_max"])
first_installation_year__lte=filters["first_installation_year_max"]
)
# Installation count filter
if filters.get("min_installations"):
@@ -172,18 +191,22 @@ class RideModelListCreateAPIView(APIView):
# Height filters
if filters.get("min_height_ft"):
qs = qs.filter(
typical_height_range_max_ft__gte=filters["min_height_ft"])
typical_height_range_max_ft__gte=filters["min_height_ft"]
)
if filters.get("max_height_ft"):
qs = qs.filter(
typical_height_range_min_ft__lte=filters["max_height_ft"])
typical_height_range_min_ft__lte=filters["max_height_ft"]
)
# Speed filters
if filters.get("min_speed_mph"):
qs = qs.filter(
typical_speed_range_max_mph__gte=filters["min_speed_mph"])
typical_speed_range_max_mph__gte=filters["min_speed_mph"]
)
if filters.get("max_speed_mph"):
qs = qs.filter(
typical_speed_range_min_mph__lte=filters["max_speed_mph"])
typical_speed_range_min_mph__lte=filters["max_speed_mph"]
)
# Ordering
ordering = filters.get("ordering", "manufacturer__name,name")
@@ -203,7 +226,10 @@ class RideModelListCreateAPIView(APIView):
description="Create a new ride model for a specific manufacturer.",
parameters=[
OpenApiParameter(
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
name="manufacturer_slug",
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
required=True,
),
],
request=RideModelCreateInputSerializer,
@@ -262,13 +288,17 @@ class RideModelListCreateAPIView(APIView):
class RideModelDetailAPIView(APIView):
permission_classes = [permissions.AllowAny]
def _get_ride_model_or_404(self, manufacturer_slug: str, ride_model_slug: str) -> Any:
def _get_ride_model_or_404(
self, manufacturer_slug: str, ride_model_slug: str
) -> Any:
if not MODELS_AVAILABLE:
raise NotFound("Ride model models not available")
try:
return RideModel.objects.select_related("manufacturer").prefetch_related(
"photos", "variants", "technical_specs"
).get(manufacturer__slug=manufacturer_slug, slug=ride_model_slug)
return (
RideModel.objects.select_related("manufacturer")
.prefetch_related("photos", "variants", "technical_specs")
.get(manufacturer__slug=manufacturer_slug, slug=ride_model_slug)
)
except RideModel.DoesNotExist:
raise NotFound("Ride model not found")
@@ -277,16 +307,24 @@ class RideModelDetailAPIView(APIView):
description="Get detailed information about a specific ride model.",
parameters=[
OpenApiParameter(
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
name="manufacturer_slug",
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
required=True,
),
OpenApiParameter(
name="ride_model_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
name="ride_model_slug",
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
required=True,
),
],
responses={200: RideModelDetailOutputSerializer()},
tags=["Ride Models"],
)
def get(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
def get(
self, request: Request, manufacturer_slug: str, ride_model_slug: str
) -> Response:
ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug)
serializer = RideModelDetailOutputSerializer(
ride_model, context={"request": request}
@@ -298,17 +336,25 @@ class RideModelDetailAPIView(APIView):
description="Update a ride model (partial update supported).",
parameters=[
OpenApiParameter(
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
name="manufacturer_slug",
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
required=True,
),
OpenApiParameter(
name="ride_model_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
name="ride_model_slug",
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
required=True,
),
],
request=RideModelUpdateInputSerializer,
responses={200: RideModelDetailOutputSerializer()},
tags=["Ride Models"],
)
def patch(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
def patch(
self, request: Request, manufacturer_slug: str, ride_model_slug: str
) -> Response:
ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug)
serializer_in = RideModelUpdateInputSerializer(data=request.data, partial=True)
serializer_in.is_valid(raise_exception=True)
@@ -331,7 +377,9 @@ class RideModelDetailAPIView(APIView):
)
return Response(serializer.data)
def put(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
def put(
self, request: Request, manufacturer_slug: str, ride_model_slug: str
) -> Response:
# Full replace - reuse patch behavior for simplicity
return self.patch(request, manufacturer_slug, ride_model_slug)
@@ -340,16 +388,24 @@ class RideModelDetailAPIView(APIView):
description="Delete a ride model.",
parameters=[
OpenApiParameter(
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
name="manufacturer_slug",
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
required=True,
),
OpenApiParameter(
name="ride_model_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
name="ride_model_slug",
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
required=True,
),
],
responses={204: None},
tags=["Ride Models"],
)
def delete(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
def delete(
self, request: Request, manufacturer_slug: str, ride_model_slug: str
) -> Response:
ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug)
ride_model.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -366,7 +422,10 @@ class RideModelSearchAPIView(APIView):
description="Search ride models by name, description, or manufacturer.",
parameters=[
OpenApiParameter(
name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=True
name="q",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
required=True,
)
],
responses={200: RideModelListOutputSerializer(many=True)},
@@ -384,15 +443,15 @@ class RideModelSearchAPIView(APIView):
"id": 1,
"name": "Hyper Coaster",
"manufacturer": {"name": "Bolliger & Mabillard"},
"category": "RC"
"category": "RC",
}
]
)
qs = RideModel.objects.filter(
Q(name__icontains=q) |
Q(description__icontains=q) |
Q(manufacturer__name__icontains=q)
Q(name__icontains=q)
| Q(description__icontains=q)
| Q(manufacturer__name__icontains=q)
).select_related("manufacturer")[:20]
results = [
@@ -426,54 +485,65 @@ class RideModelFilterOptionsAPIView(APIView):
def get(self, request: Request) -> Response:
"""Return filter options for ride models."""
if not MODELS_AVAILABLE:
return Response({
"categories": [("RC", "Roller Coaster"), ("FR", "Flat Ride")],
"target_markets": [("THRILL", "Thrill"), ("FAMILY", "Family")],
"manufacturers": [{"id": 1, "name": "Bolliger & Mabillard"}],
})
return Response(
{
"categories": [("RC", "Roller Coaster"), ("FR", "Flat Ride")],
"target_markets": [("THRILL", "Thrill"), ("FAMILY", "Family")],
"manufacturers": [{"id": 1, "name": "Bolliger & Mabillard"}],
}
)
# Get actual data from database
manufacturers = Company.objects.filter(
roles__contains=["MANUFACTURER"],
ride_models__isnull=False
).distinct().values("id", "name", "slug")
manufacturers = (
Company.objects.filter(
roles__contains=["MANUFACTURER"], ride_models__isnull=False
)
.distinct()
.values("id", "name", "slug")
)
categories = RideModel.objects.exclude(category="").values_list(
"category", flat=True
).distinct()
categories = (
RideModel.objects.exclude(category="")
.values_list("category", flat=True)
.distinct()
)
target_markets = RideModel.objects.exclude(target_market="").values_list(
"target_market", flat=True
).distinct()
target_markets = (
RideModel.objects.exclude(target_market="")
.values_list("target_market", flat=True)
.distinct()
)
return Response({
"categories": [
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
],
"target_markets": [
("FAMILY", "Family"),
("THRILL", "Thrill"),
("EXTREME", "Extreme"),
("KIDDIE", "Kiddie"),
("ALL_AGES", "All Ages"),
],
"manufacturers": list(manufacturers),
"ordering_options": [
("name", "Name A-Z"),
("-name", "Name Z-A"),
("manufacturer__name", "Manufacturer A-Z"),
("-manufacturer__name", "Manufacturer Z-A"),
("first_installation_year", "Oldest First"),
("-first_installation_year", "Newest First"),
("total_installations", "Fewest Installations"),
("-total_installations", "Most Installations"),
],
})
return Response(
{
"categories": [
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
],
"target_markets": [
("FAMILY", "Family"),
("THRILL", "Thrill"),
("EXTREME", "Extreme"),
("KIDDIE", "Kiddie"),
("ALL_AGES", "All Ages"),
],
"manufacturers": list(manufacturers),
"ordering_options": [
("name", "Name A-Z"),
("-name", "Name Z-A"),
("manufacturer__name", "Manufacturer A-Z"),
("-manufacturer__name", "Manufacturer Z-A"),
("first_installation_year", "Oldest First"),
("-first_installation_year", "Newest First"),
("total_installations", "Fewest Installations"),
("-total_installations", "Most Installations"),
],
}
)
# === RIDE MODEL STATISTICS ===
@@ -491,69 +561,84 @@ class RideModelStatsAPIView(APIView):
def get(self, request: Request) -> Response:
"""Get ride model statistics."""
if not MODELS_AVAILABLE:
return Response({
"total_models": 50,
"total_installations": 500,
"active_manufacturers": 15,
"discontinued_models": 10,
"by_category": {"RC": 30, "FR": 15, "WR": 5},
"by_target_market": {"THRILL": 25, "FAMILY": 20, "EXTREME": 5},
"by_manufacturer": {"Bolliger & Mabillard": 8, "Intamin": 6},
"recent_models": 3,
})
return Response(
{
"total_models": 50,
"total_installations": 500,
"active_manufacturers": 15,
"discontinued_models": 10,
"by_category": {"RC": 30, "FR": 15, "WR": 5},
"by_target_market": {"THRILL": 25, "FAMILY": 20, "EXTREME": 5},
"by_manufacturer": {"Bolliger & Mabillard": 8, "Intamin": 6},
"recent_models": 3,
}
)
# Calculate statistics
total_models = RideModel.objects.count()
total_installations = RideModel.objects.aggregate(
total=Count('rides')
)['total'] or 0
total_installations = (
RideModel.objects.aggregate(total=Count("rides"))["total"] or 0
)
active_manufacturers = Company.objects.filter(
roles__contains=["MANUFACTURER"],
ride_models__isnull=False
).distinct().count()
active_manufacturers = (
Company.objects.filter(
roles__contains=["MANUFACTURER"], ride_models__isnull=False
)
.distinct()
.count()
)
discontinued_models = RideModel.objects.filter(is_discontinued=True).count()
# Category breakdown
by_category = {}
category_counts = RideModel.objects.exclude(category="").values(
"category"
).annotate(count=Count("id"))
category_counts = (
RideModel.objects.exclude(category="")
.values("category")
.annotate(count=Count("id"))
)
for item in category_counts:
by_category[item["category"]] = item["count"]
# Target market breakdown
by_target_market = {}
market_counts = RideModel.objects.exclude(target_market="").values(
"target_market"
).annotate(count=Count("id"))
market_counts = (
RideModel.objects.exclude(target_market="")
.values("target_market")
.annotate(count=Count("id"))
)
for item in market_counts:
by_target_market[item["target_market"]] = item["count"]
# Manufacturer breakdown (top 10)
by_manufacturer = {}
manufacturer_counts = RideModel.objects.filter(
manufacturer__isnull=False
).values("manufacturer__name").annotate(count=Count("id")).order_by("-count")[:10]
manufacturer_counts = (
RideModel.objects.filter(manufacturer__isnull=False)
.values("manufacturer__name")
.annotate(count=Count("id"))
.order_by("-count")[:10]
)
for item in manufacturer_counts:
by_manufacturer[item["manufacturer__name"]] = item["count"]
# Recent models (last 30 days)
thirty_days_ago = timezone.now() - timedelta(days=30)
recent_models = RideModel.objects.filter(
created_at__gte=thirty_days_ago).count()
created_at__gte=thirty_days_ago
).count()
return Response({
"total_models": total_models,
"total_installations": total_installations,
"active_manufacturers": active_manufacturers,
"discontinued_models": discontinued_models,
"by_category": by_category,
"by_target_market": by_target_market,
"by_manufacturer": by_manufacturer,
"recent_models": recent_models,
})
return Response(
{
"total_models": total_models,
"total_installations": total_installations,
"active_manufacturers": active_manufacturers,
"discontinued_models": discontinued_models,
"by_category": by_category,
"by_target_market": by_target_market,
"by_manufacturer": by_manufacturer,
"recent_models": recent_models,
}
)
# === RIDE MODEL VARIANTS ===
@@ -592,7 +677,7 @@ class RideModelVariantListCreateAPIView(APIView):
if not MODELS_AVAILABLE:
return Response(
{"detail": "Variants not available"},
status=status.HTTP_501_NOT_IMPLEMENTED
status=status.HTTP_501_NOT_IMPLEMENTED,
)
try:
@@ -653,7 +738,8 @@ class RideModelVariantDetailAPIView(APIView):
def patch(self, request: Request, ride_model_pk: int, pk: int) -> Response:
variant = self._get_variant_or_404(ride_model_pk, pk)
serializer_in = RideModelVariantUpdateInputSerializer(
data=request.data, partial=True)
data=request.data, partial=True
)
serializer_in.is_valid(raise_exception=True)
for field, value in serializer_in.validated_data.items():
@@ -677,25 +763,30 @@ class RideModelVariantDetailAPIView(APIView):
# Note: Similar patterns would be implemented for RideModelTechnicalSpec and RideModelPhoto
# For brevity, I'm including the class definitions but not the full implementations
class RideModelTechnicalSpecListCreateAPIView(APIView):
"""CRUD operations for ride model technical specifications."""
permission_classes = [permissions.AllowAny]
# Implementation similar to variants...
class RideModelTechnicalSpecDetailAPIView(APIView):
"""CRUD operations for individual technical specifications."""
permission_classes = [permissions.AllowAny]
# Implementation similar to variant detail...
class RideModelPhotoListCreateAPIView(APIView):
"""CRUD operations for ride model photos."""
permission_classes = [permissions.AllowAny]
# Implementation similar to variants...
class RideModelPhotoDetailAPIView(APIView):
"""CRUD operations for individual ride model photos."""
permission_classes = [permissions.AllowAny]
# Implementation similar to variant detail...