--- name: lp-drf-api description: Creates DRF 3.16 API endpoints for league-planner using function-based views, @api_view decorator, drf-spectacular OpenAPI docs, and custom auth. Use for new API endpoints. argument-hint: [HTTP-methods...] allowed-tools: Read, Write, Edit, Glob, Grep --- # League-Planner DRF API Generator Creates REST API endpoints following league-planner patterns: function-based views with `@api_view`, drf-spectacular documentation, custom token authentication, and proper error handling. ## When to Use - Creating new API endpoints in `/api/uefa/`, `/api/court/`, `/api/configurator/`, `/api/collector/` - Adding OpenAPI documentation to existing endpoints - Implementing custom authentication or permissions - Building serializers for complex nested data ## Prerequisites - Endpoint belongs to an existing API namespace (uefa, court, configurator, collector) - Models and relationships are already defined - URL routing is set up in the appropriate `urls.py` ## Instructions ### Step 1: Create Serializer Place in `api/{namespace}/serializers.py`: ```python from rest_framework import serializers from drf_spectacular.utils import extend_schema_serializer, OpenApiExample from scheduler.models import Team, Season @extend_schema_serializer( examples=[ OpenApiExample( 'Team Response', value={ 'id': 1, 'name': 'FC Bayern München', 'country': {'code': 'DE', 'name': 'Germany'}, 'stadium': 'Allianz Arena', }, response_only=True, ), ] ) class TeamSerializer(serializers.ModelSerializer): """Serializer for Team with nested country.""" country = serializers.SerializerMethodField() class Meta: model = Team fields = ['id', 'name', 'country', 'stadium'] read_only_fields = ['id'] def get_country(self, obj) -> dict | None: if obj.country: return { 'code': obj.country.code, 'name': obj.country.name, } return None class TeamCreateSerializer(serializers.ModelSerializer): """Separate serializer for creating teams.""" class Meta: model = Team fields = ['name', 'country', 'stadium', 'latitude', 'longitude'] def validate_name(self, value: str) -> str: if Team.objects.filter(name__iexact=value).exists(): raise serializers.ValidationError('Team name already exists') return value # Request serializers (for documentation) class SeasonRequestSerializer(serializers.Serializer): """Request body for season-based endpoints.""" season_id = serializers.IntegerField(help_text='ID of the season') include_inactive = serializers.BooleanField( default=False, help_text='Include inactive items' ) class PaginatedResponseSerializer(serializers.Serializer): """Base pagination response.""" count = serializers.IntegerField() next = serializers.URLField(allow_null=True) previous = serializers.URLField(allow_null=True) results = serializers.ListField() ``` ### Step 2: Create View Function Place in `api/{namespace}/views.py`: ```python from rest_framework import status from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from drf_spectacular.utils import ( extend_schema, OpenApiParameter, OpenApiResponse, OpenApiExample, ) from drf_spectacular.types import OpenApiTypes from scheduler.models import Team, Season from .serializers import ( TeamSerializer, TeamCreateSerializer, SeasonRequestSerializer, ) @extend_schema( summary='List Teams', description=''' Returns all teams for a given season. **Authentication**: Requires valid API key or session. **Query Parameters**: - `season_id`: Required. The season to fetch teams for. - `active_only`: Optional. Filter to active teams only. ''', parameters=[ OpenApiParameter( name='season_id', type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True, description='Season ID to fetch teams for', ), OpenApiParameter( name='active_only', type=OpenApiTypes.BOOL, location=OpenApiParameter.QUERY, required=False, default=False, description='Filter to active teams only', ), ], responses={ 200: OpenApiResponse( response=TeamSerializer(many=True), description='List of teams', examples=[ OpenApiExample( 'Success', value=[ {'id': 1, 'name': 'FC Bayern', 'country': {'code': 'DE'}}, {'id': 2, 'name': 'Real Madrid', 'country': {'code': 'ES'}}, ], ), ], ), 400: OpenApiResponse(description='Invalid parameters'), 404: OpenApiResponse(description='Season not found'), }, tags=['teams'], ) @api_view(['GET']) @permission_classes([AllowAny]) def list_teams(request, version=None): """List all teams for a season.""" season_id = request.query_params.get('season_id') if not season_id: return Response( {'error': 'season_id is required'}, status=status.HTTP_400_BAD_REQUEST ) try: season = Season.objects.get(pk=season_id) except Season.DoesNotExist: return Response( {'error': 'Season not found'}, status=status.HTTP_404_NOT_FOUND ) teams = Team.objects.filter( season=season ).select_related( 'country' ).order_by('name') active_only = request.query_params.get('active_only', '').lower() == 'true' if active_only: teams = teams.filter(is_active=True) serializer = TeamSerializer(teams, many=True) return Response(serializer.data) @extend_schema( summary='Create Team', description='Create a new team in the specified season.', request=TeamCreateSerializer, responses={ 201: OpenApiResponse( response=TeamSerializer, description='Team created successfully', ), 400: OpenApiResponse(description='Validation error'), }, tags=['teams'], ) @api_view(['POST']) @permission_classes([IsAuthenticated]) def create_team(request, version=None): """Create a new team.""" serializer = TeamCreateSerializer(data=request.data) if not serializer.is_valid(): return Response( {'error': 'Validation failed', 'details': serializer.errors}, status=status.HTTP_400_BAD_REQUEST ) team = serializer.save() return Response( TeamSerializer(team).data, status=status.HTTP_201_CREATED ) @extend_schema( summary='Get Team Details', description='Retrieve detailed information about a specific team.', responses={ 200: TeamSerializer, 404: OpenApiResponse(description='Team not found'), }, tags=['teams'], ) @api_view(['GET']) def get_team(request, team_id: int, version=None): """Get team details by ID.""" try: team = Team.objects.select_related('country', 'season').get(pk=team_id) except Team.DoesNotExist: return Response( {'error': 'Team not found'}, status=status.HTTP_404_NOT_FOUND ) serializer = TeamSerializer(team) return Response(serializer.data) @extend_schema( summary='Bulk Update Teams', description='Update multiple teams in a single request.', request=TeamSerializer(many=True), responses={ 200: OpenApiResponse( description='Teams updated', response={'type': 'object', 'properties': {'updated': {'type': 'integer'}}}, ), }, tags=['teams'], ) @api_view(['PATCH']) @permission_classes([IsAuthenticated]) def bulk_update_teams(request, version=None): """Bulk update teams.""" updated_count = 0 for team_data in request.data: team_id = team_data.get('id') if not team_id: continue try: team = Team.objects.get(pk=team_id) serializer = TeamSerializer(team, data=team_data, partial=True) if serializer.is_valid(): serializer.save() updated_count += 1 except Team.DoesNotExist: continue return Response({'updated': updated_count}) ``` ### Step 3: Configure URLs In `api/{namespace}/urls.py`: ```python from django.urls import path from . import views app_name = 'api-uefa' urlpatterns = [ # v1 endpoints path('v1/teams/', views.list_teams, name='teams-list'), path('v1/teams/create/', views.create_team, name='teams-create'), path('v1/teams//', views.get_team, name='teams-detail'), path('v1/teams/bulk/', views.bulk_update_teams, name='teams-bulk'), # v2 endpoints (can have different implementations) path('v2/teams/', views.list_teams, name='teams-list-v2'), ] ``` ## Patterns & Best Practices ### Custom Authentication (Project Pattern) ```python from rest_framework.authentication import BaseAuthentication from rest_framework.exceptions import AuthenticationFailed from scheduler.models import Team class TeamTokenAuthentication(BaseAuthentication): """Authenticate via Team.hashval token.""" def authenticate(self, request): token = request.headers.get('X-Team-Token') if not token: return None try: team = Team.objects.get(hashval=token) return (None, team) # (user, auth) except Team.DoesNotExist: raise AuthenticationFailed('Invalid team token') # Usage in view @api_view(['GET']) @authentication_classes([TeamTokenAuthentication]) def team_data(request): team = request.auth # The authenticated team return Response({'team': team.name}) ``` ### Error Response Format ```python # Standard error structure used in the project def error_response(code: str, message: str, details: dict = None, status_code: int = 400): """Create standardized error response.""" response_data = { 'error': { 'code': code, 'message': message, } } if details: response_data['error']['details'] = details return Response(response_data, status=status_code) # Usage return error_response( code='VALIDATION_ERROR', message='Invalid input data', details={'season_id': ['This field is required']}, status_code=status.HTTP_400_BAD_REQUEST ) ``` ### Session-Based Access (Project Pattern) ```python @api_view(['GET']) def session_protected_view(request): """View requiring session-based authorization.""" # Check session for team access authorized_league = request.session.get('authorized_league') if not authorized_league: return Response( {'error': 'Not authorized'}, status=status.HTTP_403_FORBIDDEN ) # Check session for club access club_id = request.session.get('club') if club_id: # Handle club-specific logic pass return Response({'status': 'authorized'}) ``` ### Pagination Pattern ```python from rest_framework.pagination import PageNumberPagination class StandardPagination(PageNumberPagination): page_size = 25 page_size_query_param = 'page_size' max_page_size = 100 @api_view(['GET']) def paginated_list(request): """Manually paginate in function-based view.""" queryset = Team.objects.all().order_by('name') paginator = StandardPagination() page = paginator.paginate_queryset(queryset, request) if page is not None: serializer = TeamSerializer(page, many=True) return paginator.get_paginated_response(serializer.data) serializer = TeamSerializer(queryset, many=True) return Response(serializer.data) ``` ### Versioning Pattern ```python @api_view(['GET']) def versioned_endpoint(request, version=None): """Handle different API versions.""" if version == 'v1': # V1 response format return Response({'data': 'v1 format'}) elif version == 'v2': # V2 response format with additional fields return Response({'data': 'v2 format', 'extra': 'field'}) else: return Response( {'error': f'Unknown version: {version}'}, status=status.HTTP_400_BAD_REQUEST ) ``` ## drf-spectacular Configuration ### Settings (leagues/settings.py) ```python SPECTACULAR_SETTINGS = { 'TITLE': 'League Planner API', 'DESCRIPTION': 'API for sports league planning and optimization', 'VERSION': '2.0.0', 'SERVE_INCLUDE_SCHEMA': False, # Schema generation 'SCHEMA_PATH_PREFIX': r'/api/', 'SCHEMA_PATH_PREFIX_TRIM': True, # Component naming 'COMPONENT_SPLIT_REQUEST': True, 'COMPONENT_NO_READ_ONLY_REQUIRED': True, # Tags 'TAGS': [ {'name': 'teams', 'description': 'Team management'}, {'name': 'seasons', 'description': 'Season operations'}, {'name': 'draws', 'description': 'Tournament draws'}, ], # Security schemes 'SECURITY': [ {'ApiKeyAuth': []}, {'SessionAuth': []}, ], 'APPEND_COMPONENTS': { 'securitySchemes': { 'ApiKeyAuth': { 'type': 'apiKey', 'in': 'header', 'name': 'X-API-Key', }, 'SessionAuth': { 'type': 'apiKey', 'in': 'cookie', 'name': 'sessionid', }, }, }, } ``` ## Examples ### Example 1: Complex Nested Response ```python class DrawResponseSerializer(serializers.Serializer): """Complex nested response for draw data.""" draw_id = serializers.IntegerField() name = serializers.CharField() groups = serializers.SerializerMethodField() constraints = serializers.SerializerMethodField() def get_groups(self, obj): return [ { 'name': g.name, 'teams': [ {'id': t.id, 'name': t.name, 'country': t.country.code} for t in g.teams.select_related('country') ] } for g in obj.groups.prefetch_related('teams__country') ] def get_constraints(self, obj): return list(obj.constraints.values('type', 'team1_id', 'team2_id')) @extend_schema( summary='Get Draw Results', responses={200: DrawResponseSerializer}, tags=['draws'], ) @api_view(['GET']) def get_draw(request, draw_id: int): draw = Draw.objects.prefetch_related( 'groups__teams__country', 'constraints', ).get(pk=draw_id) serializer = DrawResponseSerializer(draw) return Response(serializer.data) ``` ### Example 2: File Upload Endpoint ```python from rest_framework.parsers import MultiPartParser, FormParser @extend_schema( summary='Upload Team Logo', request={ 'multipart/form-data': { 'type': 'object', 'properties': { 'logo': {'type': 'string', 'format': 'binary'}, }, }, }, responses={200: TeamSerializer}, tags=['teams'], ) @api_view(['POST']) @parser_classes([MultiPartParser, FormParser]) def upload_logo(request, team_id: int): team = Team.objects.get(pk=team_id) logo = request.FILES.get('logo') if not logo: return error_response('MISSING_FILE', 'Logo file is required') team.logo = logo team.save() return Response(TeamSerializer(team).data) ``` ## Common Pitfalls - **Missing `version` parameter**: Always include `version=None` in view signature for versioned APIs - **No query optimization**: Use `select_related`/`prefetch_related` before serializing - **Inconsistent error format**: Use the standard error response structure - **Missing OpenAPI docs**: Every endpoint needs `@extend_schema` for documentation - **Hardcoded URLs**: Use `reverse()` for generating URLs in responses ## Verification 1. Check OpenAPI schema: `python manage.py spectacular --color --file schema.yml` 2. View Swagger UI at `/api/schema/swagger-ui/` 3. Test endpoint with curl: ```bash curl -X GET "http://localhost:8000/api/uefa/v2/teams/?season_id=1" \ -H "Accept: application/json" ```