Files
pacnpal 1adba1b804 lol
2026-01-02 07:58:58 -05:00

10 KiB

description
description
Create a new Django REST API endpoint following ThrillWiki conventions

New API Workflow

Create a new Django REST Framework API endpoint following ThrillWiki's patterns.

Information Gathering

  1. Resource Name: What entity is this API for? (e.g., Park, Ride, Review)
  2. Operations: Which CRUD operations are needed?
    • List (GET /resources/)
    • Create (POST /resources/)
    • Retrieve (GET /resources/{id}/)
    • Update (PUT/PATCH /resources/{id}/)
    • Delete (DELETE /resources/{id}/)
  3. Permissions: Who can access?
    • Public read, authenticated write?
    • Owner only?
    • Moderator/Admin only?
  4. Filtering: What filter options are needed?
  5. Nested Resources: Does this belong under another resource?

Implementation Steps

1. Create or Update the Model

File: backend/apps/[app]/models.py

from django.db import models
from apps.core.models import BaseModel

class Resource(BaseModel):
    """A resource description"""
    
    class Status(models.TextChoices):
        DRAFT = 'draft', 'Draft'
        PUBLISHED = 'published', 'Published'
        ARCHIVED = 'archived', 'Archived'
    
    name = models.CharField(max_length=255)
    slug = models.SlugField(unique=True)
    description = models.TextField(blank=True)
    status = models.CharField(
        max_length=20, 
        choices=Status.choices, 
        default=Status.DRAFT
    )
    owner = models.ForeignKey(
        'users.User', 
        on_delete=models.CASCADE,
        related_name='resources'
    )
    
    class Meta:
        ordering = ['-created_at']
        indexes = [
            models.Index(fields=['status', '-created_at']),
        ]
    
    def __str__(self):
        return self.name

2. Create the Serializer

File: backend/apps/[app]/serializers.py

from rest_framework import serializers
from .models import Resource

class ResourceSerializer(serializers.ModelSerializer):
    """Serializer for Resource listing"""
    owner_username = serializers.CharField(source='owner.username', read_only=True)
    
    class Meta:
        model = Resource
        fields = [
            'id', 'name', 'slug', 'description', 'status',
            'owner', 'owner_username',
            'created_at', 'updated_at'
        ]
        read_only_fields = ['id', 'slug', 'owner', 'created_at', 'updated_at']


class ResourceDetailSerializer(ResourceSerializer):
    """Extended serializer for single resource view"""
    # Add related objects for detail view
    related_items = RelatedItemSerializer(many=True, read_only=True)
    
    class Meta(ResourceSerializer.Meta):
        fields = ResourceSerializer.Meta.fields + ['related_items']


class ResourceCreateSerializer(serializers.ModelSerializer):
    """Serializer for creating resources"""
    
    class Meta:
        model = Resource
        fields = ['name', 'description', 'status']
    
    def create(self, validated_data):
        # Auto-generate slug
        validated_data['slug'] = slugify(validated_data['name'])
        # Set owner from request
        validated_data['owner'] = self.context['request'].user
        return super().create(validated_data)

3. Create the ViewSet

File: backend/apps/[app]/views.py

from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter

from .models import Resource
from .serializers import (
    ResourceSerializer, 
    ResourceDetailSerializer,
    ResourceCreateSerializer
)
from .filters import ResourceFilter
from .permissions import IsOwnerOrReadOnly


class ResourceViewSet(viewsets.ModelViewSet):
    """
    API endpoint for resources.
    
    list: Get all resources (with filtering)
    create: Create a new resource (authenticated)
    retrieve: Get a single resource
    update: Update a resource (owner only)
    destroy: Delete a resource (owner only)
    """
    queryset = Resource.objects.all()
    permission_classes = [IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]
    lookup_field = 'slug'
    
    # Filtering
    filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
    filterset_class = ResourceFilter
    search_fields = ['name', 'description']
    ordering_fields = ['name', 'created_at', 'updated_at']
    ordering = ['-created_at']
    
    def get_queryset(self):
        """Optimize queries"""
        return Resource.objects.select_related(
            'owner'
        ).prefetch_related(
            'related_items'
        )
    
    def get_serializer_class(self):
        """Use different serializers for different actions"""
        if self.action == 'create':
            return ResourceCreateSerializer
        if self.action == 'retrieve':
            return ResourceDetailSerializer
        return ResourceSerializer
    
    @action(detail=True, methods=['post'])
    def publish(self, request, slug=None):
        """Publish a draft resource"""
        resource = self.get_object()
        if resource.status != Resource.Status.DRAFT:
            return Response(
                {'error': 'Only draft resources can be published'},
                status=status.HTTP_400_BAD_REQUEST
            )
        resource.status = Resource.Status.PUBLISHED
        resource.save()
        return Response(ResourceSerializer(resource).data)

4. Create Custom Filter

File: backend/apps/[app]/filters.py

import django_filters
from .models import Resource


class ResourceFilter(django_filters.FilterSet):
    """Filters for Resource API"""
    
    status = django_filters.ChoiceFilter(choices=Resource.Status.choices)
    owner = django_filters.CharFilter(field_name='owner__username')
    created_after = django_filters.DateTimeFilter(
        field_name='created_at', 
        lookup_expr='gte'
    )
    created_before = django_filters.DateTimeFilter(
        field_name='created_at', 
        lookup_expr='lte'
    )
    
    class Meta:
        model = Resource
        fields = ['status', 'owner']

5. Create Custom Permission

File: backend/apps/[app]/permissions.py

from rest_framework.permissions import BasePermission, SAFE_METHODS


class IsOwnerOrReadOnly(BasePermission):
    """Allow read to all, write only to owner"""
    
    def has_object_permission(self, request, view, obj):
        if request.method in SAFE_METHODS:
            return True
        return obj.owner == request.user


class IsModerator(BasePermission):
    """Allow access only to moderators"""
    
    def has_permission(self, request, view):
        return (
            request.user.is_authenticated and 
            request.user.is_moderator
        )

6. Register URLs

File: backend/apps/[app]/urls.py

from rest_framework.routers import DefaultRouter
from .views import ResourceViewSet

router = DefaultRouter()
router.register('resources', ResourceViewSet, basename='resource')

urlpatterns = router.urls

Add to main urls:

# backend/config/urls.py
urlpatterns = [
    ...
    path('api/v1/', include('apps.app_name.urls')),
]

7. Create Migration

python manage.py makemigrations app_name
python manage.py migrate

8. Add Tests

File: backend/apps/[app]/tests/test_views.py

import pytest
from rest_framework.test import APITestCase
from rest_framework import status
from django.urls import reverse

from apps.users.factories import UserFactory
from .factories import ResourceFactory


class TestResourceAPI(APITestCase):
    """Tests for Resource API endpoints"""
    
    def setUp(self):
        self.user = UserFactory()
        self.resource = ResourceFactory(owner=self.user)
    
    def test_list_resources_unauthenticated(self):
        """Anonymous users can list resources"""
        url = reverse('resource-list')
        response = self.client.get(url)
        self.assertEqual(response.status_code, status.HTTP_200_OK)
    
    def test_create_resource_authenticated(self):
        """Authenticated users can create resources"""
        self.client.force_authenticate(user=self.user)
        url = reverse('resource-list')
        data = {'name': 'New Resource', 'description': 'Test'}
        response = self.client.post(url, data)
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
    
    def test_create_resource_unauthenticated(self):
        """Anonymous users cannot create resources"""
        url = reverse('resource-list')
        data = {'name': 'New Resource'}
        response = self.client.post(url, data)
        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
    
    def test_update_own_resource(self):
        """Users can update their own resources"""
        self.client.force_authenticate(user=self.user)
        url = reverse('resource-detail', args=[self.resource.slug])
        response = self.client.patch(url, {'name': 'Updated'})
        self.assertEqual(response.status_code, status.HTTP_200_OK)
    
    def test_update_others_resource(self):
        """Users cannot update others' resources"""
        other_user = UserFactory()
        self.client.force_authenticate(user=other_user)
        url = reverse('resource-detail', args=[self.resource.slug])
        response = self.client.patch(url, {'name': 'Hacked'})
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

Checklist

After creating the API:

  • Model has proper fields and constraints
  • Serializers validate input correctly
  • ViewSet has proper permissions
  • Queries are optimized (select_related, prefetch_related)
  • Filtering, search, and ordering work
  • Pagination is enabled
  • URLs are registered
  • Migrations are created and applied
  • Tests pass

Output

Report what was created:

Created API: /api/v1/resources/
Methods: GET (list), POST (create), GET (detail), PATCH (update), DELETE
Permissions: IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly
Filters: status, owner, created_after, created_before
Search: name, description
Files:
  - backend/apps/[app]/models.py
  - backend/apps/[app]/serializers.py
  - backend/apps/[app]/views.py
  - backend/apps/[app]/filters.py
  - backend/apps/[app]/permissions.py
  - backend/apps/[app]/urls.py
  - backend/apps/[app]/tests/test_views.py