Refactor test utilities and enhance ASGI settings

- Cleaned up and standardized assertions in ApiTestMixin for API response validation.
- Updated ASGI settings to use os.environ for setting the DJANGO_SETTINGS_MODULE.
- Removed unused imports and improved formatting in settings.py.
- Refactored URL patterns in urls.py for better readability and organization.
- Enhanced view functions in views.py for consistency and clarity.
- Added .flake8 configuration for linting and style enforcement.
- Introduced type stubs for django-environ to improve type checking with Pylance.
This commit is contained in:
pacnpal
2025-08-20 19:51:59 -04:00
parent 69c07d1381
commit 66ed4347a9
230 changed files with 15094 additions and 11578 deletions

View File

@@ -4,52 +4,53 @@ Separates Input and Output serializers for clear boundaries.
"""
from rest_framework import serializers
from django.contrib.gis.geos import Point
from ..models import Park, ParkArea, Company, ParkReview
from ..models import Park
class ParkLocationOutputSerializer(serializers.Serializer):
"""Output serializer for park location data."""
latitude = serializers.SerializerMethodField()
longitude = serializers.SerializerMethodField()
city = serializers.SerializerMethodField()
state = serializers.SerializerMethodField()
country = serializers.SerializerMethodField()
formatted_address = serializers.SerializerMethodField()
def get_latitude(self, obj):
if hasattr(obj, 'location') and obj.location:
if hasattr(obj, "location") and obj.location:
return obj.location.latitude
return None
def get_longitude(self, obj):
if hasattr(obj, 'location') and obj.location:
if hasattr(obj, "location") and obj.location:
return obj.location.longitude
return None
def get_city(self, obj):
if hasattr(obj, 'location') and obj.location:
if hasattr(obj, "location") and obj.location:
return obj.location.city
return None
def get_state(self, obj):
if hasattr(obj, 'location') and obj.location:
if hasattr(obj, "location") and obj.location:
return obj.location.state
return None
def get_country(self, obj):
if hasattr(obj, 'location') and obj.location:
if hasattr(obj, "location") and obj.location:
return obj.location.country
return None
def get_formatted_address(self, obj):
if hasattr(obj, 'location') and obj.location:
if hasattr(obj, "location") and obj.location:
return obj.location.formatted_address
return ""
class CompanyOutputSerializer(serializers.Serializer):
"""Output serializer for company data."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
@@ -58,6 +59,7 @@ class CompanyOutputSerializer(serializers.Serializer):
class ParkAreaOutputSerializer(serializers.Serializer):
"""Output serializer for park area data."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
@@ -66,23 +68,26 @@ class ParkAreaOutputSerializer(serializers.Serializer):
class ParkListOutputSerializer(serializers.Serializer):
"""Output serializer for park list view."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
status = serializers.CharField()
description = serializers.CharField()
# Statistics
average_rating = serializers.DecimalField(max_digits=3, decimal_places=2, allow_null=True)
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True
)
coaster_count = serializers.IntegerField(allow_null=True)
ride_count = serializers.IntegerField(allow_null=True)
# Location (simplified for list view)
location = ParkLocationOutputSerializer(allow_null=True)
# Operator info
operator = CompanyOutputSerializer()
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
@@ -90,34 +95,39 @@ class ParkListOutputSerializer(serializers.Serializer):
class ParkDetailOutputSerializer(serializers.Serializer):
"""Output serializer for park detail view."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
status = serializers.CharField()
description = serializers.CharField()
# Details
opening_date = serializers.DateField(allow_null=True)
closing_date = serializers.DateField(allow_null=True)
operating_season = serializers.CharField()
size_acres = serializers.DecimalField(max_digits=10, decimal_places=2, allow_null=True)
size_acres = serializers.DecimalField(
max_digits=10, decimal_places=2, allow_null=True
)
website = serializers.URLField()
# Statistics
average_rating = serializers.DecimalField(max_digits=3, decimal_places=2, allow_null=True)
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True
)
coaster_count = serializers.IntegerField(allow_null=True)
ride_count = serializers.IntegerField(allow_null=True)
# Location (full details)
location = ParkLocationOutputSerializer(allow_null=True)
# Companies
operator = CompanyOutputSerializer()
property_owner = CompanyOutputSerializer(allow_null=True)
# Areas
areas = ParkAreaOutputSerializer(many=True)
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
@@ -125,171 +135,170 @@ class ParkDetailOutputSerializer(serializers.Serializer):
class ParkCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating parks."""
name = serializers.CharField(max_length=255)
description = serializers.CharField(allow_blank=True, default="")
status = serializers.ChoiceField(
choices=Park.STATUS_CHOICES,
default="OPERATING"
)
status = serializers.ChoiceField(choices=Park.STATUS_CHOICES, default="OPERATING")
# Optional details
opening_date = serializers.DateField(required=False, allow_null=True)
closing_date = serializers.DateField(required=False, allow_null=True)
operating_season = serializers.CharField(max_length=255, required=False, allow_blank=True)
operating_season = serializers.CharField(
max_length=255, required=False, allow_blank=True
)
size_acres = serializers.DecimalField(
max_digits=10,
decimal_places=2,
required=False,
allow_null=True
max_digits=10, decimal_places=2, required=False, allow_null=True
)
website = serializers.URLField(required=False, allow_blank=True)
# Required operator
operator_id = serializers.IntegerField()
# Optional property owner
property_owner_id = serializers.IntegerField(required=False, allow_null=True)
def validate(self, data):
"""Cross-field validation."""
opening_date = data.get('opening_date')
closing_date = data.get('closing_date')
opening_date = data.get("opening_date")
closing_date = data.get("closing_date")
if opening_date and closing_date and closing_date < opening_date:
raise serializers.ValidationError(
"Closing date cannot be before opening date"
)
return data
class ParkUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating parks."""
name = serializers.CharField(max_length=255, required=False)
description = serializers.CharField(allow_blank=True, required=False)
status = serializers.ChoiceField(
choices=Park.STATUS_CHOICES,
required=False
)
status = serializers.ChoiceField(choices=Park.STATUS_CHOICES, required=False)
# Optional details
opening_date = serializers.DateField(required=False, allow_null=True)
closing_date = serializers.DateField(required=False, allow_null=True)
operating_season = serializers.CharField(max_length=255, required=False, allow_blank=True)
operating_season = serializers.CharField(
max_length=255, required=False, allow_blank=True
)
size_acres = serializers.DecimalField(
max_digits=10,
decimal_places=2,
required=False,
allow_null=True
max_digits=10, decimal_places=2, required=False, allow_null=True
)
website = serializers.URLField(required=False, allow_blank=True)
# Companies
operator_id = serializers.IntegerField(required=False)
property_owner_id = serializers.IntegerField(required=False, allow_null=True)
def validate(self, data):
"""Cross-field validation."""
opening_date = data.get('opening_date')
closing_date = data.get('closing_date')
opening_date = data.get("opening_date")
closing_date = data.get("closing_date")
if opening_date and closing_date and closing_date < opening_date:
raise serializers.ValidationError(
"Closing date cannot be before opening date"
)
return data
class ParkFilterInputSerializer(serializers.Serializer):
"""Input serializer for park filtering and search."""
# Search
search = serializers.CharField(required=False, allow_blank=True)
# Status filter
status = serializers.MultipleChoiceField(
choices=Park.STATUS_CHOICES,
required=False
choices=Park.STATUS_CHOICES, required=False
)
# Location filters
country = serializers.CharField(required=False, allow_blank=True)
state = serializers.CharField(required=False, allow_blank=True)
city = serializers.CharField(required=False, allow_blank=True)
# Rating filter
min_rating = serializers.DecimalField(
max_digits=3,
decimal_places=2,
max_digits=3,
decimal_places=2,
required=False,
min_value=1,
max_value=10
max_value=10,
)
# Size filter
min_size_acres = serializers.DecimalField(
max_digits=10,
decimal_places=2,
required=False,
min_value=0
max_digits=10, decimal_places=2, required=False, min_value=0
)
max_size_acres = serializers.DecimalField(
max_digits=10,
decimal_places=2,
required=False,
min_value=0
max_digits=10, decimal_places=2, required=False, min_value=0
)
# Company filters
operator_id = serializers.IntegerField(required=False)
property_owner_id = serializers.IntegerField(required=False)
# Ordering
ordering = serializers.ChoiceField(
choices=[
'name', '-name',
'opening_date', '-opening_date',
'average_rating', '-average_rating',
'coaster_count', '-coaster_count',
'created_at', '-created_at'
"name",
"-name",
"opening_date",
"-opening_date",
"average_rating",
"-average_rating",
"coaster_count",
"-coaster_count",
"created_at",
"-created_at",
],
required=False,
default='name'
default="name",
)
class ParkReviewOutputSerializer(serializers.Serializer):
"""Output serializer for park reviews."""
id = serializers.IntegerField()
rating = serializers.IntegerField()
title = serializers.CharField()
content = serializers.CharField()
visit_date = serializers.DateField()
created_at = serializers.DateTimeField()
# User info (limited for privacy)
user = serializers.SerializerMethodField()
def get_user(self, obj):
return {
'username': obj.user.username,
'display_name': obj.user.get_full_name() or obj.user.username
"username": obj.user.username,
"display_name": obj.user.get_full_name() or obj.user.username,
}
class ParkStatsOutputSerializer(serializers.Serializer):
"""Output serializer for park statistics."""
total_parks = serializers.IntegerField()
operating_parks = serializers.IntegerField()
closed_parks = serializers.IntegerField()
under_construction = serializers.IntegerField()
# Averages
average_rating = serializers.DecimalField(max_digits=3, decimal_places=2, allow_null=True)
average_coaster_count = serializers.DecimalField(max_digits=5, decimal_places=2, allow_null=True)
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True
)
average_coaster_count = serializers.DecimalField(
max_digits=5, decimal_places=2, allow_null=True
)
# Top countries
top_countries = serializers.ListField(child=serializers.DictField())
# Recently added
recently_added_count = serializers.IntegerField()

View File

@@ -7,55 +7,59 @@ from rest_framework.routers import DefaultRouter
from .views import (
ParkListApi,
ParkDetailApi,
ParkDetailApi,
ParkCreateApi,
ParkUpdateApi,
ParkDeleteApi,
ParkApi
ParkApi,
)
app_name = 'parks_api'
app_name = "parks_api"
# Option 1: Separate ViewSets for each operation (more explicit)
router_separate = DefaultRouter()
router_separate.register(r'list', ParkListApi, basename='park-list')
router_separate.register(r'detail', ParkDetailApi, basename='park-detail')
router_separate.register(r'create', ParkCreateApi, basename='park-create')
router_separate.register(r'update', ParkUpdateApi, basename='park-update')
router_separate.register(r'delete', ParkDeleteApi, basename='park-delete')
router_separate.register(r"list", ParkListApi, basename="park-list")
router_separate.register(r"detail", ParkDetailApi, basename="park-detail")
router_separate.register(r"create", ParkCreateApi, basename="park-create")
router_separate.register(r"update", ParkUpdateApi, basename="park-update")
router_separate.register(r"delete", ParkDeleteApi, basename="park-delete")
# Option 2: Unified ViewSet (more conventional DRF)
router_unified = DefaultRouter()
router_unified.register(r'parks', ParkApi, basename='park')
router_unified.register(r"parks", ParkApi, basename="park")
# Use unified approach for cleaner URLs
urlpatterns = [
path('v1/', include(router_unified.urls)),
path("v1/", include(router_unified.urls)),
]
# Alternative manual URL patterns for more control
urlpatterns_manual = [
# List and create
path('v1/parks/', ParkApi.as_view({
'get': 'list',
'post': 'create'
}), name='park-list'),
path(
"v1/parks/",
ParkApi.as_view({"get": "list", "post": "create"}),
name="park-list",
),
# Stats endpoint
path('v1/parks/stats/', ParkApi.as_view({
'get': 'stats'
}), name='park-stats'),
path("v1/parks/stats/", ParkApi.as_view({"get": "stats"}), name="park-stats"),
# Detail operations
path('v1/parks/<slug:slug>/', ParkApi.as_view({
'get': 'retrieve',
'put': 'update',
'patch': 'partial_update',
'delete': 'destroy'
}), name='park-detail'),
path(
"v1/parks/<slug:slug>/",
ParkApi.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="park-detail",
),
# Park reviews
path('v1/parks/<slug:slug>/reviews/', ParkApi.as_view({
'get': 'reviews'
}), name='park-reviews'),
path(
"v1/parks/<slug:slug>/reviews/",
ParkApi.as_view({"get": "reviews"}),
name="park-reviews",
),
]

View File

@@ -3,30 +3,29 @@ Parks API views following Django styleguide patterns.
Uses ClassNameApi naming convention and proper Input/Output serializers.
"""
from typing import Any, Dict
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly
from rest_framework.permissions import (
IsAuthenticated,
IsAuthenticatedOrReadOnly,
)
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter
from core.api.mixins import (
ApiMixin,
CreateApiMixin,
UpdateApiMixin,
ListApiMixin,
CreateApiMixin,
UpdateApiMixin,
ListApiMixin,
RetrieveApiMixin,
DestroyApiMixin
DestroyApiMixin,
)
from ..selectors import (
park_list_with_stats,
park_detail_optimized,
park_reviews_for_park,
park_statistics
park_statistics,
)
from ..services import ParkService
from .serializers import (
@@ -36,165 +35,148 @@ from .serializers import (
ParkUpdateInputSerializer,
ParkFilterInputSerializer,
ParkReviewOutputSerializer,
ParkStatsOutputSerializer
ParkStatsOutputSerializer,
)
class ParkListApi(
ListApiMixin,
GenericViewSet
):
class ParkListApi(ListApiMixin, GenericViewSet):
"""
API endpoint for listing parks with filtering and search.
GET /api/v1/parks/
"""
permission_classes = [IsAuthenticatedOrReadOnly]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ['name', 'description']
ordering_fields = ['name', 'opening_date', 'average_rating', 'coaster_count', 'created_at']
ordering = ['name']
search_fields = ["name", "description"]
ordering_fields = [
"name",
"opening_date",
"average_rating",
"coaster_count",
"created_at",
]
ordering = ["name"]
OutputSerializer = ParkListOutputSerializer
FilterSerializer = ParkFilterInputSerializer
def get_queryset(self):
"""Use selector to get optimized queryset."""
# Parse filter parameters
filter_serializer = self.FilterSerializer(data=self.request.query_params)
filter_serializer.is_valid(raise_exception=True)
filters = filter_serializer.validated_data
return park_list_with_stats(filters=filters)
@action(detail=False, methods=['get'])
@action(detail=False, methods=["get"])
def stats(self, request: Request) -> Response:
"""
Get park statistics.
GET /api/v1/parks/stats/
"""
stats = park_statistics()
serializer = ParkStatsOutputSerializer(stats)
return self.create_response(
data=serializer.data,
metadata={'cache_duration': 3600} # 1 hour cache hint
metadata={"cache_duration": 3600}, # 1 hour cache hint
)
class ParkDetailApi(
RetrieveApiMixin,
GenericViewSet
):
class ParkDetailApi(RetrieveApiMixin, GenericViewSet):
"""
API endpoint for retrieving individual park details.
GET /api/v1/parks/{id}/
"""
permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = 'slug'
lookup_field = "slug"
OutputSerializer = ParkDetailOutputSerializer
def get_object(self):
"""Use selector for optimized detail query."""
slug = self.kwargs.get('slug')
slug = self.kwargs.get("slug")
return park_detail_optimized(slug=slug)
@action(detail=True, methods=['get'])
@action(detail=True, methods=["get"])
def reviews(self, request: Request, slug: str = None) -> Response:
"""
Get reviews for a specific park.
GET /api/v1/parks/{slug}/reviews/
"""
park = self.get_object()
reviews = park_reviews_for_park(park_id=park.id, limit=50)
serializer = ParkReviewOutputSerializer(reviews, many=True)
return self.create_response(
data=serializer.data,
metadata={
'total_reviews': len(reviews),
'park_name': park.name
}
metadata={"total_reviews": len(reviews), "park_name": park.name},
)
class ParkCreateApi(
CreateApiMixin,
GenericViewSet
):
class ParkCreateApi(CreateApiMixin, GenericViewSet):
"""
API endpoint for creating parks.
POST /api/v1/parks/create/
"""
permission_classes = [IsAuthenticated]
InputSerializer = ParkCreateInputSerializer
OutputSerializer = ParkDetailOutputSerializer
def perform_create(self, **validated_data):
"""Create park using service layer."""
return ParkService.create_park(**validated_data)
class ParkUpdateApi(
UpdateApiMixin,
RetrieveApiMixin,
GenericViewSet
):
class ParkUpdateApi(UpdateApiMixin, RetrieveApiMixin, GenericViewSet):
"""
API endpoint for updating parks.
PUT /api/v1/parks/{slug}/update/
PATCH /api/v1/parks/{slug}/update/
"""
permission_classes = [IsAuthenticated]
lookup_field = 'slug'
lookup_field = "slug"
InputSerializer = ParkUpdateInputSerializer
OutputSerializer = ParkDetailOutputSerializer
def get_object(self):
"""Use selector for optimized detail query."""
slug = self.kwargs.get('slug')
slug = self.kwargs.get("slug")
return park_detail_optimized(slug=slug)
def perform_update(self, instance, **validated_data):
"""Update park using service layer."""
return ParkService.update_park(
park_id=instance.id,
**validated_data
)
return ParkService.update_park(park_id=instance.id, **validated_data)
class ParkDeleteApi(
DestroyApiMixin,
RetrieveApiMixin,
GenericViewSet
):
class ParkDeleteApi(DestroyApiMixin, RetrieveApiMixin, GenericViewSet):
"""
API endpoint for deleting parks.
DELETE /api/v1/parks/{slug}/delete/
"""
permission_classes = [IsAuthenticated] # TODO: Add staff/admin permission
lookup_field = 'slug'
lookup_field = "slug"
def get_object(self):
"""Use selector for optimized detail query."""
slug = self.kwargs.get('slug')
slug = self.kwargs.get("slug")
return park_detail_optimized(slug=slug)
def perform_destroy(self, instance):
"""Delete park using service layer."""
ParkService.delete_park(park_id=instance.id)
@@ -207,11 +189,11 @@ class ParkApi(
ListApiMixin,
RetrieveApiMixin,
DestroyApiMixin,
GenericViewSet
GenericViewSet,
):
"""
Unified API endpoint for parks with all CRUD operations.
GET /api/v1/parks/ - List parks
POST /api/v1/parks/ - Create park
GET /api/v1/parks/{slug}/ - Get park detail
@@ -219,96 +201,95 @@ class ParkApi(
PATCH /api/v1/parks/{slug}/ - Partial update park
DELETE /api/v1/parks/{slug}/ - Delete park
"""
permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = 'slug'
lookup_field = "slug"
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ['name', 'description']
ordering_fields = ['name', 'opening_date', 'average_rating', 'coaster_count', 'created_at']
ordering = ['name']
search_fields = ["name", "description"]
ordering_fields = [
"name",
"opening_date",
"average_rating",
"coaster_count",
"created_at",
]
ordering = ["name"]
# Serializers for different operations
InputSerializer = ParkCreateInputSerializer # Used for create
UpdateInputSerializer = ParkUpdateInputSerializer # Used for update
OutputSerializer = ParkDetailOutputSerializer # Used for retrieve
ListOutputSerializer = ParkListOutputSerializer # Used for list
FilterSerializer = ParkFilterInputSerializer
def get_queryset(self):
"""Use selector to get optimized queryset."""
if self.action == 'list':
if self.action == "list":
# Parse filter parameters for list view
filter_serializer = self.FilterSerializer(data=self.request.query_params)
filter_serializer.is_valid(raise_exception=True)
filters = filter_serializer.validated_data
return park_list_with_stats(**filters)
# For detail views, this won't be used since we override get_object
return []
def get_object(self):
"""Use selector for optimized detail query."""
slug = self.kwargs.get('slug')
slug = self.kwargs.get("slug")
return park_detail_optimized(slug=slug)
def get_output_serializer(self, *args, **kwargs):
"""Return appropriate output serializer based on action."""
if self.action == 'list':
if self.action == "list":
return self.ListOutputSerializer(*args, **kwargs)
return self.OutputSerializer(*args, **kwargs)
def get_input_serializer(self, *args, **kwargs):
"""Return appropriate input serializer based on action."""
if self.action in ['update', 'partial_update']:
if self.action in ["update", "partial_update"]:
return self.UpdateInputSerializer(*args, **kwargs)
return self.InputSerializer(*args, **kwargs)
def perform_create(self, **validated_data):
"""Create park using service layer."""
return ParkService.create_park(**validated_data)
def perform_update(self, instance, **validated_data):
"""Update park using service layer."""
return ParkService.update_park(
park_id=instance.id,
**validated_data
)
return ParkService.update_park(park_id=instance.id, **validated_data)
def perform_destroy(self, instance):
"""Delete park using service layer."""
ParkService.delete_park(park_id=instance.id)
@action(detail=False, methods=['get'])
@action(detail=False, methods=["get"])
def stats(self, request: Request) -> Response:
"""
Get park statistics.
GET /api/v1/parks/stats/
"""
stats = park_statistics()
serializer = ParkStatsOutputSerializer(stats)
return self.create_response(
data=serializer.data,
metadata={'cache_duration': 3600}
data=serializer.data, metadata={"cache_duration": 3600}
)
@action(detail=True, methods=['get'])
@action(detail=True, methods=["get"])
def reviews(self, request: Request, slug: str = None) -> Response:
"""
Get reviews for a specific park.
GET /api/v1/parks/{slug}/reviews/
"""
park = self.get_object()
reviews = park_reviews_for_park(park_id=park.id, limit=50)
serializer = ParkReviewOutputSerializer(reviews, many=True)
return self.create_response(
data=serializer.data,
metadata={
'total_reviews': len(reviews),
'park_name': park.name
}
metadata={"total_reviews": len(reviews), "park_name": park.name},
)