# Django Performance Enhancement Implementation Plan ## Executive Summary This document provides a comprehensive implementation plan for enhancing the ThrillWiki Django application across three priority areas: **API Standardization**, **Performance Enhancement**, and **Monitoring & Observability**. The plan leverages existing Django modules and follows Django styleguide best practices while building upon the current project's solid architectural foundation. ## Current Project Analysis ### Existing Strengths - ✅ **Django REST Framework Integration**: Comprehensive DRF setup with Input/Output serializer patterns - ✅ **Service Layer Architecture**: Well-implemented service/selector pattern following Django styleguide - ✅ **Custom Exception Handling**: Standardized error handling with structured logging - ✅ **Performance Awareness**: Existing caching service and performance monitoring infrastructure - ✅ **Modern Django Stack**: Current dependencies include `djangorestframework`, `django-redis`, `sentry-sdk` ### Current Implementations ```python # Existing API Pattern (parks/api/views.py) class ParkApi(CreateApiMixin, UpdateApiMixin, ListApiMixin, RetrieveApiMixin, DestroyApiMixin, GenericViewSet): InputSerializer = ParkCreateInputSerializer OutputSerializer = ParkDetailOutputSerializer FilterSerializer = ParkFilterInputSerializer ``` ```python # Existing Cache Service (core/services/map_cache_service.py) class MapCacheService: DEFAULT_TTL = 3600 # 1 hour CLUSTER_TTL = 7200 # 2 hours # Geographic partitioning with Redis ``` ```python # Existing Logging (core/logging.py) def log_exception(logger, exception, *, context=None, request=None): # Structured logging with context ``` ## Priority 1: API Standardization ### 1.1 Nested Serializers Enhancement **Current State**: Basic Input/Output serializer separation exists **Goal**: Migrate to fully inline nested serializers #### Implementation Plan **Phase 1: Audit Current Serializers** ```bash # Add to pyproject.toml dependencies (already exists) "djangorestframework>=3.14.0" ``` **Phase 2: Enhance Nested Serializer Patterns** ```python # Enhanced pattern for parks/api/serializers.py class ParkCreateInputSerializer(serializers.Serializer): class LocationInputSerializer(serializers.Serializer): latitude = serializers.DecimalField(max_digits=9, decimal_places=6) longitude = serializers.DecimalField(max_digits=9, decimal_places=6) city = serializers.CharField(max_length=100) state = serializers.CharField(max_length=100) country = serializers.CharField(max_length=100) class OperatorInputSerializer(serializers.Serializer): name = serializers.CharField(max_length=200) website = serializers.URLField(required=False) name = serializers.CharField(max_length=200) description = serializers.CharField(allow_blank=True) location = LocationInputSerializer() operator = OperatorInputSerializer(required=False) opening_date = serializers.DateField(required=False) ``` **Implementation Tasks:** 1. **Enhance existing serializers** in `parks/api/serializers.py` and `rides/api/serializers.py` 2. **Create reusable nested serializers** for common patterns (Location, Company, etc.) 3. **Update API mixins** in `core/api/mixins.py` to handle nested validation 4. **Add serializer composition utilities** for complex nested structures ### 1.2 OpenAPI Documentation Implementation **Recommended Module**: `drf-spectacular` (modern, actively maintained) #### Implementation Plan **Phase 1: Install and Configure** ```bash # Add to pyproject.toml "drf-spectacular>=0.27.0" ``` **Phase 2: Configuration** ```python # config/django/base.py additions INSTALLED_APPS = [ # ... existing apps 'drf_spectacular', ] REST_FRAMEWORK = { # ... existing settings 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', } SPECTACULAR_SETTINGS = { 'TITLE': 'ThrillWiki API', 'DESCRIPTION': 'Comprehensive theme park and ride information API', 'VERSION': '1.0.0', 'SERVE_INCLUDE_SCHEMA': False, 'COMPONENT_SPLIT_REQUEST': True, 'TAGS': [ {'name': 'parks', 'description': 'Theme park operations'}, {'name': 'rides', 'description': 'Ride information and management'}, {'name': 'locations', 'description': 'Geographic location services'}, ] } ``` **Phase 3: URL Configuration** ```python # thrillwiki/urls.py additions from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView urlpatterns = [ # ... existing patterns path('api/schema/', SpectacularAPIView.as_view(), name='schema'), path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), ] ``` **Phase 4: Enhanced Documentation** ```python # Enhanced API views with documentation from drf_spectacular.utils import extend_schema, OpenApiParameter class ParkApi(CreateApiMixin, UpdateApiMixin, ListApiMixin, RetrieveApiMixin, DestroyApiMixin, GenericViewSet): @extend_schema( summary="Create a new theme park", description="Creates a new theme park with location and operator information", tags=['parks'], responses={201: ParkDetailOutputSerializer} ) def create(self, request, *args, **kwargs): return super().create(request, *args, **kwargs) @extend_schema( summary="List theme parks", description="Retrieve a paginated list of theme parks with filtering options", parameters=[ OpenApiParameter(name='search', description='Search parks by name', type=str), OpenApiParameter(name='country', description='Filter by country', type=str), ], tags=['parks'] ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) ``` ### 1.3 API Versioning Strategy Enhancement **Current State**: Basic URL-based routing exists **Goal**: Comprehensive versioning with backward compatibility #### Implementation Plan **Phase 1: Configure DRF Versioning** ```python # config/django/base.py REST_FRAMEWORK = { # ... existing settings 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.URLPathVersioning', 'ALLOWED_VERSIONS': ['v1', 'v2'], 'DEFAULT_VERSION': 'v1', 'VERSION_PARAM': 'version' } ``` **Phase 2: Versioned URL Structure** ```python # New structure for API URLs # thrillwiki/urls.py urlpatterns = [ # ... existing patterns path('api/v1/', include('core.urls.api_v1', namespace='api-v1')), path('api/v2/', include('core.urls.api_v2', namespace='api-v2')), # Future version ] # core/urls/api_v1.py from django.urls import path, include urlpatterns = [ path('parks/', include('parks.api.urls')), path('rides/', include('rides.api.urls')), path('locations/', include('location.api.urls')), ] ``` **Phase 3: Version-Aware Serializers** ```python # Enhanced API mixins with versioning support class VersionedApiMixin: def get_serializer_class(self): version = getattr(self.request, 'version', 'v1') serializer_name = f"{self.__class__.__name__.replace('Api', '')}Serializer_v{version}" # Fallback to default if version-specific serializer doesn't exist try: return getattr(self, serializer_name, self.serializer_class) except AttributeError: return self.serializer_class ``` ## Priority 2: Performance Enhancement ### 2.1 Redis Caching Strategy Implementation **Current State**: `django-redis` already in dependencies, `MapCacheService` exists **Goal**: Comprehensive multi-layer caching strategy #### Implementation Plan **Phase 1: Enhanced Redis Configuration** ```python # config/django/base.py enhancement CACHES = { 'default': { 'BACKEND': 'django_redis.cache.RedisCache', 'LOCATION': env('REDIS_URL', default='redis://127.0.0.1:6379/1'), 'OPTIONS': { 'CLIENT_CLASS': 'django_redis.client.DefaultClient', 'PARSER_CLASS': 'redis.connection.HiredisParser', 'CONNECTION_POOL_CLASS': 'redis.BlockingConnectionPool', 'CONNECTION_POOL_CLASS_KWARGS': { 'max_connections': 50, 'timeout': 20, }, 'COMPRESSOR': 'django_redis.compressors.zlib.ZlibCompressor', 'IGNORE_EXCEPTIONS': True, }, 'KEY_PREFIX': 'thrillwiki', 'VERSION': 1, }, 'sessions': { 'BACKEND': 'django_redis.cache.RedisCache', 'LOCATION': env('REDIS_URL', default='redis://127.0.0.1:6379/2'), 'OPTIONS': { 'CLIENT_CLASS': 'django_redis.client.DefaultClient', } }, 'api': { 'BACKEND': 'django_redis.cache.RedisCache', 'LOCATION': env('REDIS_URL', default='redis://127.0.0.1:6379/3'), 'OPTIONS': { 'CLIENT_CLASS': 'django_redis.client.DefaultClient', } } } # Use Redis for sessions SESSION_ENGINE = 'django.contrib.sessions.backends.cache' SESSION_CACHE_ALIAS = 'sessions' SESSION_COOKIE_AGE = 86400 # 24 hours ``` **Phase 2: Enhanced Caching Service** ```python # core/services/enhanced_cache_service.py from typing import Optional, Any, Dict, List from django.core.cache import caches from django.core.cache.utils import make_template_fragment_key import hashlib import json class EnhancedCacheService: """Comprehensive caching service with multiple cache backends""" def __init__(self): self.default_cache = caches['default'] self.api_cache = caches['api'] # L1: Query-level caching def cache_queryset(self, cache_key: str, queryset_func, timeout: int = 3600, **kwargs): """Cache expensive querysets""" cached_result = self.default_cache.get(cache_key) if cached_result is None: result = queryset_func(**kwargs) self.default_cache.set(cache_key, result, timeout) return result return cached_result # L2: API response caching def cache_api_response(self, view_name: str, params: Dict, response_data: Any, timeout: int = 1800): """Cache API responses based on view and parameters""" cache_key = self._generate_api_cache_key(view_name, params) self.api_cache.set(cache_key, response_data, timeout) def get_cached_api_response(self, view_name: str, params: Dict) -> Optional[Any]: """Retrieve cached API response""" cache_key = self._generate_api_cache_key(view_name, params) return self.api_cache.get(cache_key) # L3: Geographic caching (building on existing MapCacheService) def cache_geographic_data(self, bounds: 'GeoBounds', data: Any, zoom_level: int, timeout: int = 1800): """Cache geographic data with spatial keys""" # Leverage existing MapCacheService implementation pass def _generate_api_cache_key(self, view_name: str, params: Dict) -> str: """Generate consistent cache keys for API responses""" params_str = json.dumps(params, sort_keys=True) params_hash = hashlib.md5(params_str.encode()).hexdigest() return f"api:{view_name}:{params_hash}" ``` **Phase 3: Caching Decorators and Mixins** ```python # core/decorators/cache_decorators.py from functools import wraps from django.core.cache import cache def cache_api_response(timeout=1800, vary_on=None): """Decorator for caching API responses""" def decorator(view_func): @wraps(view_func) def wrapper(self, request, *args, **kwargs): if request.method != 'GET': return view_func(self, request, *args, **kwargs) # Generate cache key based on view, user, and parameters cache_key_parts = [ view_func.__name__, str(request.user.id) if request.user.is_authenticated else 'anonymous', str(hash(frozenset(request.GET.items()))) ] if vary_on: for field in vary_on: cache_key_parts.append(str(getattr(request, field, ''))) cache_key = ':'.join(cache_key_parts) # Try to get from cache cached_response = cache.get(cache_key) if cached_response: return cached_response # Execute view and cache result response = view_func(self, request, *args, **kwargs) if response.status_code == 200: cache.set(cache_key, response, timeout) return response return wrapper return decorator # Usage in API views class ParkApi(GenericViewSet): @cache_api_response(timeout=3600, vary_on=['version']) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) ``` ### 2.2 Database Optimization and Query Monitoring **Recommended Modules**: `django-silk` (comprehensive), `django-debug-toolbar` (development) #### Implementation Plan **Phase 1: Install Monitoring Tools** ```bash # Add to pyproject.toml "django-silk>=5.0.0" "django-debug-toolbar>=4.0.0" # Development only "nplusone>=1.0.0" # N+1 query detection ``` **Phase 2: Configuration** ```python # config/django/local.py (development) INSTALLED_APPS = [ # ... existing apps 'silk', 'debug_toolbar', 'nplusone.ext.django', ] MIDDLEWARE = [ 'silk.middleware.SilkyMiddleware', 'debug_toolbar.middleware.DebugToolbarMiddleware', 'nplusone.ext.django.NPlusOneMiddleware', # ... existing middleware ] # Silk configuration SILKY_PYTHON_PROFILER = True SILKY_PYTHON_PROFILER_BINARY = True SILKY_PYTHON_PROFILER_RESULT_PATH = BASE_DIR / 'profiles' # Debug toolbar configuration INTERNAL_IPS = ['127.0.0.1', '::1'] # NPlusOne configuration NPLUSONE_LOGGER = logging.getLogger('nplusone') NPLUSONE_LOG_LEVEL = logging.WARN ``` **Phase 3: Query Optimization Utilities** ```python # core/utils/query_optimization.py from django.db import connection from django.conf import settings import logging import time from contextlib import contextmanager logger = logging.getLogger('query_optimization') @contextmanager def track_queries(operation_name: str): """Context manager to track database queries for specific operations""" if not settings.DEBUG: yield return initial_queries = len(connection.queries) start_time = time.time() try: yield finally: end_time = time.time() total_queries = len(connection.queries) - initial_queries execution_time = end_time - start_time if total_queries > 10 or execution_time > 1.0: logger.warning( f"Performance concern in {operation_name}: " f"{total_queries} queries, {execution_time:.2f}s" ) # Enhanced selector patterns with query optimization def park_list_optimized(*, filters: Optional[Dict] = None) -> QuerySet: """Optimized park list query with proper select_related and prefetch_related""" queryset = Park.objects.select_related( 'location', 'operator', 'created_by' ).prefetch_related( 'areas', 'rides__manufacturer', 'reviews__user' ).annotate( ride_count=Count('rides'), average_rating=Avg('reviews__rating'), latest_review_date=Max('reviews__created_at') ) if filters: queryset = queryset.filter(**filters) return queryset.order_by('name') ``` **Phase 4: Database Index Optimization** ```python # Enhanced model indexes based on common queries class Park(TimeStampedModel): class Meta: indexes = [ models.Index(fields=['slug']), models.Index(fields=['status', 'created_at']), models.Index(fields=['location', 'status']), models.Index(fields=['operator', 'status']), models.Index(fields=['-average_rating', 'status']), # For top-rated parks models.Index(fields=['opening_date', 'status']), # For chronological queries ] # Add database-level constraints constraints = [ models.CheckConstraint( check=models.Q(average_rating__gte=0) & models.Q(average_rating__lte=5), name='valid_rating_range' ), ] ``` ### 2.3 Cloudflare Images CDN Integration **Current State**: WhiteNoise for static files, local media storage **Goal**: Cloudflare Images for media optimization and delivery, WhiteNoise for static files [Cloudflare Images](https://developers.cloudflare.com/images/) provides an end-to-end solution for image storage, transformation, and delivery on Cloudflare's global network. This is ideal for ThrillWiki's image-heavy content (park photos, ride images, user submissions). #### Implementation Plan **Phase 1: Enhanced Static File Configuration** ```python # config/django/production.py STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' # Enhanced WhiteNoise configuration for static files (CSS, JS) WHITENOISE_USE_FINDERS = True WHITENOISE_AUTOREFRESH = False WHITENOISE_MAX_AGE = 31536000 # 1 year WHITENOISE_SKIP_COMPRESS_EXTENSIONS = ['webp', 'avif'] # Static file optimization STATICFILES_FINDERS = [ 'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.AppDirectoriesFinder', ] ``` **Phase 2: Cloudflare Images Integration with django-cloudflare-images** ```bash # Add to pyproject.toml - Use the official django-cloudflare-images package "django-cloudflare-images>=0.6.0" # Latest version as of May 2024 ``` ```python # config/django/base.py - Cloudflare Images configuration # Using django-cloudflare-images package for simplified integration # Storage configuration (Django 4.2+) STORAGES = { "default": { "BACKEND": "cloudflare_images.storage.CloudflareImagesStorage" }, "staticfiles": { "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage" } } # For Django < 4.2 (fallback) DEFAULT_FILE_STORAGE = "cloudflare_images.storage.CloudflareImagesStorage" # Cloudflare Images configuration CLOUDFLARE_IMAGES_ACCOUNT_ID = env('CLOUDFLARE_IMAGES_ACCOUNT_ID') CLOUDFLARE_IMAGES_API_TOKEN = env('CLOUDFLARE_IMAGES_API_TOKEN') # Images:Edit permission CLOUDFLARE_IMAGES_ACCOUNT_HASH = env('CLOUDFLARE_IMAGES_ACCOUNT_HASH') # Optional: Custom domain for image delivery CLOUDFLARE_IMAGES_DOMAIN = env('CLOUDFLARE_IMAGES_DOMAIN', default=None) # e.g., "images.thrillwiki.com" # Optional: Default variant for serving images CLOUDFLARE_IMAGES_VARIANT = env('CLOUDFLARE_IMAGES_VARIANT', default='public') # Optional: API timeout override CLOUDFLARE_IMAGES_API_TIMEOUT = env('CLOUDFLARE_IMAGES_API_TIMEOUT', default=60, cast=int) ``` **Phase 3: Enhanced Model Fields with CloudflareImagesField** ```python # parks/models/parks.py - Enhanced with CloudflareImagesField from cloudflare_images.field import CloudflareImagesField from django.db import models class Park(TimeStampedModel): # ... existing fields ... # Replace ImageField with CloudflareImagesField for variant support featured_image = CloudflareImagesField( variant="hero", # Use 'hero' variant by default for park featured images upload_to='parks/', blank=True, null=True, help_text="Main park image displayed on detail pages" ) # Additional image fields with specific variants thumbnail_image = CloudflareImagesField( variant="thumbnail", upload_to='parks/thumbnails/', blank=True, null=True, help_text="Thumbnail image for park listings" ) # rides/models/rides.py - Enhanced ride images class Ride(TimeStampedModel): # ... existing fields ... main_image = CloudflareImagesField( variant="large", upload_to='rides/', blank=True, null=True, help_text="Primary ride image" ) gallery_images = models.ManyToManyField( 'media.RideImage', blank=True, related_name='rides', help_text="Additional ride photos" ) # media/models.py - Gallery and user upload models class RideImage(TimeStampedModel): """Individual ride images for galleries""" image = CloudflareImagesField( variant="medium", upload_to='rides/gallery/', help_text="Ride gallery image" ) caption = models.CharField(max_length=200, blank=True) photographer = models.CharField(max_length=100, blank=True) is_approved = models.BooleanField(default=False) class UserSubmission(TimeStampedModel): """User-submitted images for moderation""" image = CloudflareImagesField( variant="public", # Use public variant for moderation workflow upload_to='submissions/', help_text="User-submitted image awaiting moderation" ) submitted_by = models.ForeignKey('accounts.User', on_delete=models.CASCADE) content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField() content_object = GenericForeignKey('content_type', 'object_id') is_approved = models.BooleanField(default=False) moderation_notes = models.TextField(blank=True) ``` **Phase 4: Enhanced Cloudflare Images Service Layer** ```python # media/services/cloudflare_optimization.py from django.conf import settings from typing import Dict, List, Optional import logging logger = logging.getLogger(__name__) class CloudflareImagesService: """ Enhanced service for Cloudflare Images operations Works with django-cloudflare-images package """ def __init__(self): self.account_hash = settings.CLOUDFLARE_IMAGES_ACCOUNT_HASH self.domain = getattr(settings, 'CLOUDFLARE_IMAGES_DOMAIN', None) self.base_url = f"https://{self.domain}" if self.domain else "https://imagedelivery.net" def get_image_url(self, image_id: str, variant: str = 'public', **transforms) -> str: """ Generate Cloudflare Images URL with optional transformations Args: image_id: Cloudflare image ID (from CloudflareImagesField) variant: Predefined variant or 'public' for custom transforms transforms: Custom transformation parameters (width, height, fit, format, etc.) """ if not image_id: return '' if transforms: # Build custom transformation string transform_parts = [] for key, value in transforms.items(): transform_parts.append(f"{key}={value}") variant = ','.join(transform_parts) return f"{self.base_url}/{self.account_hash}/{image_id}/{variant}" def get_responsive_urls(self, image_id: str) -> Dict[str, str]: """ Generate responsive image URLs for different screen sizes Uses Cloudflare's automatic optimization and format selection """ if not image_id: return {} return { # Standard variants for different use cases 'thumbnail': self.get_image_url(image_id, width=150, height=150, fit='cover'), 'small': self.get_image_url(image_id, width=300, height=300, fit='cover'), 'medium': self.get_image_url(image_id, width=600, height=600, fit='cover'), 'large': self.get_image_url(image_id, width=1200, height=1200, fit='cover'), 'hero': self.get_image_url(image_id, width=1920, height=1080, fit='cover'), # WebP variants for modern browsers 'webp_small': self.get_image_url(image_id, width=300, height=300, fit='cover', format='webp'), 'webp_medium': self.get_image_url(image_id, width=600, height=600, fit='cover', format='webp'), 'webp_large': self.get_image_url(image_id, width=1200, height=1200, fit='cover', format='webp'), # AVIF for ultra-modern browsers 'avif_medium': self.get_image_url(image_id, width=600, height=600, fit='cover', format='avif'), # Original (Cloudflare will still optimize based on request headers) 'original': self.get_image_url(image_id, 'public'), } def get_srcset_string(self, image_id: str, sizes: List[int] = None) -> str: """ Generate srcset string for responsive images Args: image_id: Cloudflare image ID sizes: List of widths for srcset (defaults to common breakpoints) """ if not image_id: return '' sizes = sizes or [320, 640, 768, 1024, 1280, 1536, 1920] srcset_parts = [] for width in sizes: url = self.get_image_url(image_id, width=width, fit='cover') srcset_parts.append(f"{url} {width}w") return ', '.join(srcset_parts) def optimize_for_context(self, image_id: str, context: str = 'default') -> str: """ Get optimized image URL based on usage context Args: image_id: Cloudflare image ID context: Usage context (hero, card, thumbnail, avatar, etc.) """ context_configs = { 'hero': {'width': 1920, 'height': 1080, 'fit': 'cover', 'quality': 85}, 'card': {'width': 400, 'height': 300, 'fit': 'cover', 'quality': 80}, 'thumbnail': {'width': 150, 'height': 150, 'fit': 'cover', 'quality': 75}, 'avatar': {'width': 100, 'height': 100, 'fit': 'cover', 'quality': 80}, 'gallery': {'width': 800, 'height': 600, 'fit': 'cover', 'quality': 85}, 'list_item': {'width': 300, 'height': 200, 'fit': 'cover', 'quality': 75}, } config = context_configs.get(context, {'width': 600, 'height': 400, 'fit': 'cover'}) return self.get_image_url(image_id, **config) # Template integration helpers class CloudflareImagesTemplateService: """Enhanced template integration for Cloudflare Images""" @staticmethod def get_picture_element(image_id: str, alt_text: str = '', css_classes: str = '', context: str = 'default') -> str: """ Generate modern picture element with format-based source selection Provides AVIF, WebP, and fallback support """ if not image_id: return f'
' service = CloudflareImagesService() urls = service.get_responsive_urls(image_id) srcset = service.get_srcset_string(image_id) return f"""