mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 06:05:18 -05:00
361 lines
10 KiB
Markdown
361 lines
10 KiB
Markdown
---
|
|
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`
|
|
|
|
```python
|
|
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`
|
|
|
|
```python
|
|
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`
|
|
|
|
```python
|
|
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`
|
|
|
|
```python
|
|
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`
|
|
|
|
```python
|
|
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`
|
|
|
|
```python
|
|
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:
|
|
```python
|
|
# backend/config/urls.py
|
|
urlpatterns = [
|
|
...
|
|
path('api/v1/', include('apps.app_name.urls')),
|
|
]
|
|
```
|
|
|
|
### 7. Create Migration
|
|
|
|
```bash
|
|
python manage.py makemigrations app_name
|
|
python manage.py migrate
|
|
```
|
|
|
|
### 8. Add Tests
|
|
|
|
File: `backend/apps/[app]/tests/test_views.py`
|
|
|
|
```python
|
|
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
|
|
```
|