Build a Movie Review API with DRF
Build a multi-model REST API with JWT auth, nested serializers, and rate limiting.
Introduction
30 Second Summary
Every app with user-generated content needs an API that handles who can read, who can edit, and how to stop one user from overwhelming the system. Movie review sites are a perfect example.
In this project, you will build a Movie Review API using Django REST Framework. You will implement multi-model relationships, JWT authentication, custom permissions, rate limiting, and nested serializers across three different view styles.
What You'll Build
You request a JWT token, create a movie review, see it nested inside the movie's full response, and then watch the API reject your attempt to edit another user's review and throttle you when you send too many requests.
By the end of this project, you'll have:
- Post a review for any movie and see it appear nested inside that movie's detail response alongside genres and existing reviews.
- Get a 403 Forbidden when you try to edit another user's review, proving your custom object-level permission enforces owner-only writes.
- Hit the API too fast and receive a 429 Too Many Requests response. Then create a movie with genres attached in a single POST through a writable nested serializer.
- Secret Mission: Add a custom endpoint that returns aggregated movie statistics (average rating, review count) using Django ORM annotations.
Are there any prerequisites?
This project assumes you have completed a basic DRF project covering single-model CRUD, ModelSerializer, and token auth. You should be comfortable with Python, Django models, and basic REST concepts.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up the Project
You need a Django project with DRF and JWT authentication configured before you can build any endpoints.
In this step, you will create a virtual environment, install Django, DRF, and the SimpleJWT library, then configure settings and URL routing so the development server boots cleanly.
In this step, get ready to:
- Install Django, DRF, and SimpleJWT in a virtual environment.
- Configure DRF authentication, throttling, and JWT settings.
- Wire JWT token endpoints and create a test superuser.
Check your Python version
Django 5.2 requires Python 3.10 or higher. Let's confirm you have a compatible version installed.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows) and searching for Terminal.
- Check your Python version by running:
python3 --version
✔️ I see version 3.10 or higher
You are good to go. Move on to the next section.
ⓧ I see a version lower than 3.10
You need Python 3.10+ for this project. Download the latest Python installer from python.org/downloads and run it.
- On macOS, download the macOS 64-bit universal2 installer and run it.
- On Windows, download the Windows installer (64-bit) and make sure to check Add python.exe to PATH during installation.
- After installation, close and reopen your terminal, then run python3 --version again to confirm.
ⓧ Command not found
Python is not installed yet. Download it from python.org/downloads.
- On macOS, download the macOS 64-bit universal2 installer and run it.
- On Windows, download the Windows installer (64-bit) and make sure to check Add python.exe to PATH during installation.
- After installation, close and reopen your terminal, then run python3 --version again to confirm it shows 3.10 or higher.
Create the project and install dependencies
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir movie-review-api
cd movie-review-api
- Create a virtual environment and activate it by running:
macOS
python3 -m venv venv
source venv/bin/activate
Windows
python -m venv venv
venv\Scripts\activate
Your terminal prompt now shows (venv) at the start, confirming the virtual environment is active.
- Install Django, DRF, and the SimpleJWT package by running:
pip install django==5.2.16 djangorestframework==3.17.1 djangorestframework-simplejwt==5.5.1
What are these three packages?
Django is the web framework that handles URL routing, database models, and the admin interface.
djangorestframework (DRF) adds serializers, viewsets, and the browsable API on top of Django.
djangorestframework-simplejwt provides JWT token authentication so users can log in without sessions.
- Scaffold the Django project by running:
django-admin startproject movieapi .
What does the dot do?
The . tells Django to create the project in the current directory instead of nesting it inside another folder. This keeps your file structure flat and easier to navigate.
- Create the reviews app by running:
python manage.py startapp reviews
- Confirm the project files exist by running:
ls
You should see manage.py, a movieapi/ folder, a reviews/ folder, and a venv/ folder in the output.
Don't see these files?
- Make sure you ran django-admin startproject movieapi . with the trailing dot. Without it, Django creates a nested folder structure.
- Confirm your virtual environment is active (you should see (venv) in your prompt). If not, re-run the activation command.
Help me troubleshoot my Django project setup.
Configure DRF and JWT in settings
Django needs to know about your installed packages and how authentication should work. You will register the apps, set up JWT as the default authentication method, and configure rate limiting.
- Open Cursor by pressing Cmd+Space (macOS) or the Windows key (Windows) and searching for Cursor.
- Click File in the top menu bar.
- Click Open Folder.
- Select the movie-review-api folder on your Desktop, then click Open.
- In the Explorer sidebar, open movieapi/settings.py.
- Find the INSTALLED_APPS list. Replace it with this version and add the REST_FRAMEWORK configuration below it:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'rest_framework_simplejwt',
'reviews',
]
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle',
],
'DEFAULT_THROTTLE_RATES': {
'anon': '100/hour',
'user': '200/hour',
},
}
What does this configuration do?
- The three new entries in INSTALLED_APPS register DRF, SimpleJWT, and your reviews app with Django.
- DEFAULT_AUTHENTICATION_CLASSES tells DRF to use JWT tokens for authentication instead of session cookies.
- DEFAULT_THROTTLE_CLASSES enables rate limiting for both anonymous and authenticated users.
- DEFAULT_THROTTLE_RATES caps anonymous users at 100 requests per hour and authenticated users at 200.
- Save movieapi/settings.py.
- Open the terminal in Cursor by pressing Ctrl+` (backtick key).
- Run the initial database migrations to confirm settings load without errors:
python manage.py migrate
You should see a list of migrations being applied (like Applying admin.0001_initial... OK). This confirms Django recognized all three new apps.
Seeing ModuleNotFoundError?
- Make sure your virtual environment is active. Look for (venv) at the start of your terminal prompt. If missing, re-run the activation command from the platform tabs above.
- Confirm you installed all three packages with pip install. Run pip list to verify they appear in the output.
Help me fix a ModuleNotFoundError in my Django settings.
- Now add the JWT token settings. Scroll to the very top of movieapi/settings.py and add this import on the first line (above the existing Path import):
from datetime import timedelta
- Then scroll to the bottom of the file and add the SIMPLE_JWT configuration below the REST_FRAMEWORK dict:
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
'ROTATE_REFRESH_TOKENS': False,
'BLACKLIST_AFTER_ROTATION': False,
'AUTH_HEADER_TYPES': ('Bearer',),
}
What do these JWT settings control?
ACCESS_TOKEN_LIFETIME sets how long a login token stays valid (30 minutes). After that, the client must refresh it.
REFRESH_TOKEN_LIFETIME sets how long the refresh token lasts (1 day). Once expired, the user must log in again.
AUTH_HEADER_TYPES tells DRF to expect tokens in the format Authorization: Bearer <token>.
- Save movieapi/settings.py.
Wire URL routing and create a superuser
Your settings are ready. Now you need to wire up the URL patterns so Django knows where to route API requests and token endpoints.
- Open movieapi/urls.py in Cursor and replace its entire contents with:
from django.contrib import admin
from django.urls import path, include
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
urlpatterns = [
path('admin/', admin.site.urls),
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('api/', include('reviews.urls')),
]
What does this URL configuration do?
- api/token/ is where clients POST username and password to receive a JWT access and refresh token pair.
- api/token/refresh/ lets clients exchange an expired access token for a new one using their refresh token.
- api/ delegates to the reviews app, which will hold all the movie and review endpoints.
- The reviews app needs its own urls.py file. Create a new file at reviews/urls.py and add:
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
urlpatterns = [
path('', include(router.urls)),
]
Why is the router empty?
The DefaultRouter will register ViewSets later. For now, it serves the DRF browsable API root at /api/, which is enough to verify everything is wired correctly.
- Save reviews/urls.py.
- Create a superuser so you have credentials for testing. Run this in your terminal:
python manage.py createsuperuser
What will this command ask?
The command will prompt you for a username, email (optional), and password. Choose something simple you will remember for testing (for example, username admin and a short password). Django may warn about a weak password. Type y to confirm.
Before you start the server, what do you expect to see at /api/? Think about what a DRF browsable API root looks like when no views are registered yet.
- Start the development server by running:
python manage.py runserver
- Open your browser and go to http://127.0.0.1:8000/api/.
You should see the DRF browsable API page with an empty root (no registered endpoints yet, just the default router page). This confirms Django, DRF, and SimpleJWT are all loaded without errors.
Seeing an error page instead?
- If you see ModuleNotFoundError: No module named 'reviews.views', make sure your reviews/urls.py file exists and contains from . import views. Django needs a views.py in the reviews folder (it was created by startapp).
- If you see ImproperlyConfigured, double-check your INSTALLED_APPS list has no trailing commas or typos in the app names.
Help me fix my Django development server error.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all your files.
ⓧ I'd like to double check the full code
Here is the full movieapi/urls.py:
from django.contrib import admin
from django.urls import path, include
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
urlpatterns = [
path('admin/', admin.site.urls),
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('api/', include('reviews.urls')),
]
Here is the full reviews/urls.py:
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
urlpatterns = [
path('', include(router.urls)),
]
For movieapi/settings.py, confirm these additions are present: from datetime import timedelta at the top, the updated INSTALLED_APPS list, the REST_FRAMEWORK dict, and the SIMPLE_JWT dict. Scroll through the file and check each is present and matches the code blocks above.
Your foundation is solid. You have Django, DRF, and JWT authentication configured and the server running cleanly. Next up, you will define your data models and get your first working API endpoint returning live data.
Build Models and Your First Endpoint
Your Django project is wired up with JWT auth and throttling, but there is nothing to authenticate against yet. Before you can practice serializers, permissions, or nested data, you need a real data model with relationships between movies, genres, and reviews.
In this step you will define four interconnected models and expose a basic movies endpoint through a ModelViewSet. Getting a working endpoint running immediately proves the scaffolding works and gives you live data to query for the rest of the project.
In this step, get ready to:
- Define four models with ForeignKey and ManyToMany (through model) relationships.
- Create a basic MovieSerializer and MovieViewSet with a DefaultRouter.
- Seed test data and confirm the browsable API shows movies.
Define the Genre and Movie models
Your API needs to represent movies, the genres they belong to, and how those two relate. You will start with the simplest model (Genre) and build up to Movie, which references Genre through a many-to-many relationship.
- Open reviews/models.py in Cursor and replace its contents with the following code:
from django.conf import settings
from django.core.validators import MinValueValidator, MaxValueValidator
from django.db import models
class Genre(models.Model):
# Each genre has a unique name (e.g. Action, Comedy)
name = models.CharField(max_length=100, unique=True)
def __str__(self):
return self.name
class Movie(models.Model):
title = models.CharField(max_length=200)
release_year = models.PositiveIntegerField()
description = models.TextField(blank=True)
# The user who added this movie to the catalog
creator = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='created_movies',
)
# ManyToMany through a custom join table
genres = models.ManyToManyField(Genre, through='MovieGenre', related_name='movies')
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.title} ({self.release_year})"
What does this code do?
Genre is a simple lookup model. Each genre has a unique name so you cannot accidentally create duplicate entries.
Movie stores the core film data. The creator ForeignKey links each movie to the user who added it. The genres field uses through='MovieGenre' so Django routes the relationship through a custom join table you will define next.
Now add the through model directly below the Movie class in the same file:
class MovieGenre(models.Model):
RELEVANCE_CHOICES = [
('primary', 'Primary'),
('secondary', 'Secondary'),
]
movie = models.ForeignKey(Movie, on_delete=models.CASCADE, related_name='movie_genres')
genre = models.ForeignKey(Genre, on_delete=models.CASCADE, related_name='genre_movies')
# Metadata about the relationship itself
relevance = models.CharField(max_length=10, choices=RELEVANCE_CHOICES, default='primary')
class Meta:
unique_together = ('movie', 'genre')
def __str__(self):
return f"{self.movie.title} - {self.genre.name} ({self.relevance})"
Why use a through model instead of a plain ManyToMany?
A plain ManyToManyField only records that a relationship exists. A through model lets you store metadata about the relationship itself. Here, relevance records whether a genre is the primary or secondary genre for a movie.
The unique_together constraint prevents the same genre from being linked to a movie twice.
- Confirm Django detects your models so far by running:
python manage.py makemigrations
You should see output listing the new migration file with lines like + Create model Genre and + Create model Movie.
Seeing "No changes detected"?
Make sure you saved reviews/models.py before running the command. Also confirm 'reviews' is listed in INSTALLED_APPS in settings.py.
help me debug why makemigrations says no changes detected
Now add the final model. Paste this below the MovieGenre class in reviews/models.py:
class Review(models.Model):
movie = models.ForeignKey(Movie, on_delete=models.CASCADE, related_name='reviews')
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='reviews',
)
rating = models.IntegerField(
validators=[MinValueValidator(1), MaxValueValidator(5)]
)
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
# One review per user per movie
unique_together = ('movie', 'author')
def __str__(self):
return f"{self.author.username} - {self.movie.title} ({self.rating}/5)"
What does Review do?
Each review links a user (author) to a movie with a rating between 1 and 5. The unique_together constraint means a user can only review a movie once, preventing duplicate reviews.
- Save reviews/models.py.
- Create and apply all migrations by running:
python manage.py makemigrations && python manage.py migrate
You should see a second migration created for the Review model, then all migrations applied with OK status.
Migration error mentioning a missing table?
If you see an error about a missing table or dependency, make sure all four model classes (Genre, Movie, MovieGenre, Review) are in a single reviews/models.py file with the imports at the top.
help me fix my Django migration error
✔️ Awesome, I've got everything!
Great. Double check you saved reviews/models.py and that migrations ran without errors.
ⓧ I'd like to double check the full code
Scroll up and confirm your reviews/models.py contains these sections in order:
- Imports (from django.conf import settings, from django.core.validators..., from django.db import models).
- Genre class with a unique name CharField.
- Movie class with title, release_year, description, creator FK, genres M2M through MovieGenre, and created_at.
- MovieGenre class with movie FK, genre FK, relevance choices, and unique_together.
- Review class with movie FK, author FK, rating (1-5), body, created_at, and unique_together.
Create a serializer and viewset for movies
Models define how data is stored. A serializer defines how that data looks when sent over the wire as JSON. A viewset handles the HTTP verbs (GET, POST, PUT, DELETE) and wires them to the serializer.
- Create a new file reviews/serializers.py and add the following code:
from rest_framework import serializers
from .models import Genre, Movie, MovieGenre, Review
class MovieSerializer(serializers.ModelSerializer):
# Display the creator's username instead of a user ID
creator = serializers.ReadOnlyField(source='creator.username')
class Meta:
model = Movie
fields = ['id', 'title', 'release_year', 'description', 'creator']
What does this serializer do?
ModelSerializer automatically generates fields based on the model. The creator override uses ReadOnlyField(source='creator.username') so the JSON response shows a readable username like "admin" instead of a raw user ID.
- Create a new file reviews/views.py and add the following code:
from rest_framework import viewsets
from .models import Movie
from .serializers import MovieSerializer
class MovieViewSet(viewsets.ModelViewSet):
# Expose full CRUD for movies with no permission restrictions
queryset = Movie.objects.all()
serializer_class = MovieSerializer
What does ModelViewSet give you?
A single ModelViewSet class gives you list, create, retrieve, update, and delete endpoints automatically. You only need to specify the queryset and serializer_class.
The viewset exists, but DRF does not know about it until you register it with a router.
- In reviews/urls.py, find this line:
router = DefaultRouter()
- Add the following line directly below it so the file now reads:
router = DefaultRouter()
router.register(r'movies', views.MovieViewSet, basename='movie')
- Save all three files (reviews/serializers.py, reviews/views.py, reviews/urls.py).
- Start the development server by running:
python manage.py runserver
- Open http://127.0.0.1:8000/api/movies/ in your browser.
You should see the DRF browsable API page for the movie list endpoint. The list is empty because you have not created any movies yet.
Seeing an import error or 404?
If you see an ImportError, confirm that reviews/views.py imports MovieSerializer from .serializers and that reviews/urls.py imports views from . import views. If you see a 404, make sure you navigated to /api/movies/ with the trailing slash.
help me fix my DRF viewset import or URL error
Seed test data and confirm the endpoint works
An empty list is not very interesting. You will seed a few genres and movies so you have real data to query throughout the project.
- Stop the development server with Ctrl+C.
- Open the Django shell by running:
python manage.py shell
- Paste the following code into the shell to create genres, movies, and link them:
from reviews.models import Genre, Movie, MovieGenre
from django.contrib.auth.models import User
# Get the superuser you created in Step 1
user = User.objects.first()
# Create three genres
action = Genre.objects.create(name='Action')
comedy = Genre.objects.create(name='Comedy')
drama = Genre.objects.create(name='Drama')
# Create two movies
matrix = Movie.objects.create(title='The Matrix', release_year=1999, description='A hacker discovers reality is simulated.', creator=user)
inception = Movie.objects.create(title='Inception', release_year=2010, description='A thief enters dreams to plant ideas.', creator=user)
# Link genres through the through model
MovieGenre.objects.create(movie=matrix, genre=action, relevance='primary')
MovieGenre.objects.create(movie=inception, genre=action, relevance='primary')
MovieGenre.objects.create(movie=inception, genre=drama, relevance='secondary')
print(f"Created {Genre.objects.count()} genres and {Movie.objects.count()} movies.")
You should see the output Created 3 genres and 2 movies. confirming the data was saved.
- Exit the shell by typing:
exit()
- Start the server again by running:
python manage.py runserver
Before you check the browser, what do you expect to see at the movies endpoint? Think about what fields your serializer exposes.
- Open http://127.0.0.1:8000/api/movies/ in your browser.
You should see a JSON list containing your two movies (The Matrix and Inception) with their id, title, release_year, description, and creator fields. Notice that genres are not visible yet. That is because your serializer only exposes the basic fields. You will add nested genre and review data in the next step.
- Try creating a new movie using the HTML form at the bottom of the browsable API page. Enter a title, release year, and description, then click POST.
The movie appears in the list immediately. Now try clicking the red DELETE button on any movie. It works without asking for credentials. Anyone can create and delete movies without logging in. That is the problem you will fix in Step 4.
Why is this wide open?
Your REST_FRAMEWORK settings specify JWTAuthentication but you have not set DEFAULT_PERMISSION_CLASSES yet. DRF defaults to AllowAny when no permission classes are configured, so every request is allowed through.
Your data models are in place and you have a working endpoint. Next up, you will upgrade the serializers to nest genre and review data inside movie responses, and build two more view styles so you can compare the DRF view spectrum side by side.
Add Nested Serializers and the View Spectrum
Your movie API returns data, but the response shows flat fields with no relationship information. A front-end developer consuming this API would see no genres and no reviews on a movie. They would need extra requests to gather related data.
In this step, you will upgrade your serializers to nest related data inside the movie response. You will also build three different view styles side by side: generic class-based views for genres, raw APIView for reviews, and the existing ModelViewSet for movies.
In this step, get ready to:
- Build nested serializers that include genres and reviews inside movie responses.
- Create genre endpoints using generic class-based views.
- Create review endpoints using raw APIView with manual HTTP method handling.
Build nested read serializers
A nested serializer embeds the full representation of a related object inside the parent's JSON response. Instead of seeing "genres": [1, 2], the API consumer sees the actual genre names and metadata.
- Open reviews/serializers.py in Cursor.
- Replace the entire file contents with the following imports and supporting serializers:
from rest_framework import serializers
from .models import Genre, Movie, MovieGenre, Review
# Serializes genre data for read responses
class GenreSerializer(serializers.ModelSerializer):
class Meta:
model = Genre
fields = ['id', 'name']
# Serializes review data with author username
class ReviewSerializer(serializers.ModelSerializer):
author = serializers.ReadOnlyField(source='author.username')
class Meta:
model = Review
fields = ['id', 'movie', 'author', 'rating', 'body', 'created_at']
read_only_fields = ['created_at']
What does this code do?
- GenreSerializer converts a Genre model instance into JSON with id and name.
- ReviewSerializer exposes the author's username as a read-only field instead of their numeric ID.
- Below ReviewSerializer, add the through-model serializers:
# Nests genre info through the MovieGenre through model
class MovieGenreSerializer(serializers.ModelSerializer):
genre = GenreSerializer(read_only=True)
class Meta:
model = MovieGenre
fields = ['genre', 'relevance']
# Accepts genre assignment data for creating movies
class GenreAssignmentSerializer(serializers.Serializer):
genre_id = serializers.IntegerField()
relevance = serializers.ChoiceField(choices=['primary', 'secondary'], default='primary')
What do these serializers do?
- MovieGenreSerializer goes through the MovieGenre through model to include both genre details and the relevance field.
- GenreAssignmentSerializer defines the write-only input shape for attaching genres when creating a movie.
- Finally, add the updated MovieSerializer below GenreAssignmentSerializer:
class MovieSerializer(serializers.ModelSerializer):
creator = serializers.ReadOnlyField(source='creator.username')
# Nest genres through the through model
genres = MovieGenreSerializer(source='movie_genres', many=True, read_only=True)
# Nest all reviews for this movie
reviews = ReviewSerializer(many=True, read_only=True)
# Accept genre data on create (write-only)
genre_assignments = GenreAssignmentSerializer(many=True, write_only=True, required=False)
class Meta:
model = Movie
fields = [
'id', 'title', 'release_year', 'description',
'creator', 'genres', 'reviews', 'genre_assignments', 'created_at',
]
read_only_fields = ['created_at']
def create(self, validated_data):
# Pop nested data before creating the movie
genre_assignments = validated_data.pop('genre_assignments', [])
movie = Movie.objects.create(**validated_data)
for assignment in genre_assignments:
MovieGenre.objects.create(
movie=movie,
genre_id=assignment['genre_id'],
relevance=assignment['relevance'],
)
return movie
What does this serializer do?
The genres field uses source='movie_genres' to follow the related_name on your through model's ForeignKey back to Movie. This gives you genre names plus relevance in every response.
The create() override pops genre_assignments from the validated data before creating the Movie, then loops through the assignments to create MovieGenre records. DRF does not support writable nested fields by default, so this manual step is required.
- Save reviews/serializers.py.
- Return to your terminal where the dev server is running. If it stopped, start it again by running:
python manage.py runserver
- Open http://127.0.0.1:8000/api/movies/1/ in your browser.
You should see the movie JSON now includes a genres array with nested genre objects (each showing name and relevance) and a reviews array (empty for now, since no reviews exist yet).
Seeing an error instead of nested data?
- If you see AttributeError, check that source='movie_genres' matches the related_name='movie_genres' on the MovieGenre model's ForeignKey to Movie.
- If genres show as an empty list even though you seeded data, confirm you created MovieGenre records (not just Genre records). The through model must have entries linking movies to genres.
- If you get a 404, make sure a movie with ID 1 exists. Try /api/movies/ first to see all movies and their IDs.
Help me debug my nested serializer response.
Build genre views with generic class-based views
DRF's generic views give you full CRUD with minimal code. You declare a queryset and a serializer class, and the generic view handles the HTTP methods for you.
Why generics for genres?
Genre endpoints are straightforward list/create/retrieve/update/delete with no custom logic. Generic views like ListCreateAPIView and RetrieveUpdateDestroyAPIView are the perfect fit: less boilerplate than raw APIView, but more explicit than a ViewSet.
- Open reviews/views.py and replace the entire file with the following:
from rest_framework import generics, viewsets
from .models import Genre, Movie
from .serializers import GenreSerializer, MovieSerializer
# --- Genre: Generic Class-Based Views ---
class GenreListCreate(generics.ListCreateAPIView):
queryset = Genre.objects.all()
serializer_class = GenreSerializer
class GenreDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = Genre.objects.all()
serializer_class = GenreSerializer
# --- Movie: ModelViewSet with Router ---
class MovieViewSet(viewsets.ModelViewSet):
queryset = Movie.objects.all()
serializer_class = MovieSerializer
What does this code do?
- GenreListCreate handles GET (list all genres) and POST (create a genre) at /api/genres/.
- GenreDetail handles GET, PUT, and DELETE for a single genre at /api/genres/<id>/.
- The existing MovieViewSet stays unchanged. It uses the Router pattern you set up in the previous step.
- Now open reviews/urls.py and replace its contents with the following to wire the genre routes:
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register(r'movies', views.MovieViewSet, basename='movie')
urlpatterns = [
path('genres/', views.GenreListCreate.as_view(), name='genre-list'),
path('genres/<int:pk>/', views.GenreDetail.as_view(), name='genre-detail'),
path('', include(router.urls)),
]
Why wire URLs manually here?
Generic views are wired with path() and .as_view(). The Router only works with ViewSets. This is one trade-off of generics: you control the URLs explicitly, while ViewSets get automatic URL generation.
- Save both files.
- Open http://127.0.0.1:8000/api/genres/ in your browser.
You should see the genre list with the genres you seeded earlier (Action, Comedy, Drama). The browsable API also shows a POST form at the bottom where you can create a new genre.
Seeing a 404 or import error?
- Check that reviews/urls.py uses from . import views (dot import), not from reviews import views.
- Make sure the class names in urls.py match exactly: GenreListCreate and GenreDetail.
Help me fix my genre endpoint.
Build review views with raw APIView
Raw APIView gives you complete control over each HTTP method. You implement get(), post(), put(), and delete() yourself. This is more verbose than generics but lets you add custom logic at every point.
- In reviews/views.py, replace the imports at the top of the file with these expanded imports:
from django.shortcuts import get_object_or_404
from rest_framework import generics, permissions, status, viewsets
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import Genre, Movie, Review
from .serializers import GenreSerializer, MovieSerializer, ReviewSerializer
- Below the GenreDetail class (and above MovieViewSet), add the ReviewListCreate view:
# --- Review: Raw APIView ---
class ReviewListCreate(APIView):
def get(self, request):
# Optional filter: ?movie=1 returns only that movie's reviews
movie_id = request.query_params.get('movie')
reviews = Review.objects.all()
if movie_id:
reviews = reviews.filter(movie_id=movie_id)
serializer = ReviewSerializer(reviews, many=True)
return Response(serializer.data)
def post(self, request):
serializer = ReviewSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Set author to the currently authenticated user
serializer.save(author=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
What does this code do?
- The get() method lists all reviews, with an optional ?movie=1 query parameter to filter by movie.
- The post() method validates the incoming data, then saves with author=request.user so the review is automatically linked to whoever made the request.
- Notice there is no queryset or serializer_class declaration. With raw APIView you handle everything manually.
- Below ReviewListCreate (still above MovieViewSet), add the ReviewDetail view:
class ReviewDetail(APIView):
def get_object(self, pk):
review = get_object_or_404(Review, pk=pk)
self.check_object_permissions(self.request, review)
return review
def get(self, request, pk):
review = self.get_object(pk)
serializer = ReviewSerializer(review)
return Response(serializer.data)
def put(self, request, pk):
review = self.get_object(pk)
serializer = ReviewSerializer(review, data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
def delete(self, request, pk):
review = self.get_object(pk)
review.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
Why call check_object_permissions manually?
Generic views call check_object_permissions automatically when you use get_object(). With raw APIView, you must call it yourself. Without this line, object-level permissions (which you will add in the next step) would be silently skipped.
- Now update reviews/urls.py to include the review routes. Replace the file with:
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register(r'movies', views.MovieViewSet, basename='movie')
urlpatterns = [
path('genres/', views.GenreListCreate.as_view(), name='genre-list'),
path('genres/<int:pk>/', views.GenreDetail.as_view(), name='genre-detail'),
path('reviews/', views.ReviewListCreate.as_view(), name='review-list'),
path('reviews/<int:pk>/', views.ReviewDetail.as_view(), name='review-detail'),
path('', include(router.urls)),
]
- Save both reviews/views.py and reviews/urls.py.
Before you test, think about what will happen when you POST a review. The post() method calls serializer.save(author=request.user). What do you think request.user will be if you send the request without a JWT token?
- First confirm the review list works by opening http://127.0.0.1:8000/api/reviews/ in your browser.
You should see an empty list [] (no reviews exist yet). The GET request works without authentication.
- To create a review, you need to authenticate. Obtain a JWT token by running this in a new terminal:
curl -X POST http://127.0.0.1:8000/api/token/ -H "Content-Type: application/json" -d '{"username": "admin", "password": "your-superuser-password-here"}'
What does this command do?
This hits the JWT token endpoint you configured in Step 1. It returns an access token and a refresh token. You will use the access token in the next command to prove your identity.
- Copy the access token value from the response. Then create a review by running:
curl -X POST http://127.0.0.1:8000/api/reviews/ -H "Content-Type: application/json" -H "Authorization: Bearer your-access-token-here" -d '{"movie": 1, "rating": 5, "body": "An absolute masterpiece."}'
You should see a 201 Created response with your review data, including "author": "admin" set automatically from your token.
- Now refresh http://127.0.0.1:8000/api/movies/1/ in your browser.
The movie detail response now shows your review nested inside the reviews array. Three view styles, one API, all working together.
Getting an error when POSTing a review?
- If you see a 500 error mentioning AnonymousUser, your token was not included or was malformed. Make sure the Authorization header reads Bearer <token> with a space between Bearer and the token string.
- If you see "movie": ["Invalid pk"], confirm movie ID 1 exists by checking /api/movies/. Use the correct movie ID in your POST body.
- If you see a token error about expiration, generate a fresh token with the first curl command.
Help me fix my review POST request.
✔️ Awesome, I've got everything!
Great. Confirm you have saved all three files: reviews/serializers.py, reviews/views.py, and reviews/urls.py.
ⓧ I'd like to double check the full code
Scroll up and confirm each of these sections is present in your files, in order:
In reviews/serializers.py:
- Imports from rest_framework and your models.
- GenreSerializer with fields id and name.
- ReviewSerializer with author as ReadOnlyField.
- MovieGenreSerializer nesting GenreSerializer.
- GenreAssignmentSerializer with genre_id and relevance.
- MovieSerializer with nested genres, reviews, genre_assignments, and a create() override.
In reviews/views.py:
- Imports including get_object_or_404, generics, APIView, and Response.
- GenreListCreate and GenreDetail generic views.
- ReviewListCreate with get() and post() methods.
- ReviewDetail with get_object(), get(), put(), and delete() methods.
- MovieViewSet at the bottom.
Here is the final reviews/urls.py:
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register(r'movies', views.MovieViewSet, basename='movie')
urlpatterns = [
path('genres/', views.GenreListCreate.as_view(), name='genre-list'),
path('genres/<int:pk>/', views.GenreDetail.as_view(), name='genre-detail'),
path('reviews/', views.ReviewListCreate.as_view(), name='review-list'),
path('reviews/<int:pk>/', views.ReviewDetail.as_view(), name='review-detail'),
path('', include(router.urls)),
]
You now have three view patterns running side by side. Generic class-based views handle genres with two lines of config each. Raw APIView gives you full control over reviews with explicit method implementations. The MovieViewSet automates everything through the Router. In the next step, you will lock these endpoints down so only authenticated owners can edit their own resources.
Add JWT Authentication and Custom Permissions
Your API is wide open right now. Try deleting someone else's movie in the browsable API. It works. Anyone can create, edit, or delete any resource without logging in.
JWT authentication gates access by requiring a valid token with every request. A custom object-level permission ensures only the owner of a resource can modify or delete it. Together, they close the security gap your API currently has.
In this step, get ready to:
- Lock down all endpoints with a global permission setting.
- Create a custom permission class that restricts edits to resource owners.
- Test the full JWT authentication flow end-to-end.
Add global permission defaults to REST_FRAMEWORK settings
DRF's DEFAULT_PERMISSION_CLASSES setting applies a permission check to every view in your project. Setting it to IsAuthenticated means no request succeeds without a valid token.
- In movieapi/settings.py, find your REST_FRAMEWORK dictionary.
- Find this line:
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
- Directly below it, add the DEFAULT_PERMISSION_CLASSES key so the dictionary now includes:
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
What does this setting do?
This tells DRF to require authentication on every single endpoint by default. Any view that does not explicitly override permission_classes will reject unauthenticated requests.
- Save movieapi/settings.py.
Before you test this, what do you think will happen if you open the browsable API in your browser without logging in?
- Make sure your dev server is running by running:
python manage.py runserver
- Open http://127.0.0.1:8000/api/movies/ in your browser.
You get a 401 Unauthorized response with the message Authentication credentials were not provided. on every endpoint. Even a simple GET request is blocked.
This is intentional. You just locked down the entire API globally. The next substep creates per-view overrides that let unauthenticated users read data while still requiring a token for writes.
Still seeing movie data without a 401?
Make sure you saved settings.py and that the dev server restarted. Django's dev server auto-restarts on file changes, but if you have multiple terminal tabs open, confirm you are looking at the correct server instance.
help me debug why my API still allows unauthenticated requests after setting DEFAULT_PERMISSION_CLASSES
Create the custom permission class and apply per-view permissions
The global lock is too strict for a public API. You want unauthenticated users to browse movies, genres, and reviews, but only authenticated owners should be able to edit or delete their own resources.
- Create a new file called reviews/permissions.py and add the following code:
from rest_framework import permissions
class IsOwnerOrReadOnly(permissions.BasePermission):
"""
Object-level permission: any authenticated user can read,
but only the owner can edit or delete.
"""
def has_object_permission(self, request, view, obj):
# Allow GET, HEAD, OPTIONS for any request
if request.method in permissions.SAFE_METHODS:
return True
# Check the owner field depending on the model
if hasattr(obj, 'author'):
return obj.author == request.user
if hasattr(obj, 'creator'):
return obj.creator == request.user
return False
What does this permission class do?
DRF calls has_object_permission whenever a view retrieves a specific object. This class allows any request using a safe HTTP method (GET, HEAD, OPTIONS) to pass through.
For write methods (PUT, PATCH, DELETE), it checks whether the requesting user owns the object. Reviews use an author field while Movies use a creator field. The hasattr checks make the permission reusable across both models.
- Save reviews/permissions.py.
Now you need to update reviews/views.py to import the new permission and apply per-view overrides.
- In reviews/views.py, find this import block at the top of the file:
from .models import Genre, Movie, Review
from .serializers import GenreSerializer, MovieSerializer, ReviewSerializer
- Add the IsOwnerOrReadOnly import between these two lines, so the imports now look like:
from .models import Genre, Movie, Review
from .permissions import IsOwnerOrReadOnly
from .serializers import GenreSerializer, MovieSerializer, ReviewSerializer
Next, add permission_classes to each view class. This overrides the global IsAuthenticated with IsAuthenticatedOrReadOnly so unauthenticated users can still read data.
- Find your GenreListCreate class and add permission_classes so it looks like:
class GenreListCreate(generics.ListCreateAPIView):
queryset = Genre.objects.all()
serializer_class = GenreSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
- Do the same for GenreDetail:
class GenreDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = Genre.objects.all()
serializer_class = GenreSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
- Add permission_classes to ReviewListCreate as a class attribute above the get method:
class ReviewListCreate(APIView):
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
def get(self, request):
- For ReviewDetail, add both IsAuthenticatedOrReadOnly and IsOwnerOrReadOnly since this view handles single-object edits:
class ReviewDetail(APIView):
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]
def get_object(self, pk):
- Save reviews/views.py and confirm the dev server restarts without errors.
- Open http://127.0.0.1:8000/api/genres/ in your browser.
You should see the genre list again. Unauthenticated GET requests now work on endpoints with IsAuthenticatedOrReadOnly.
Still getting a 401 on GET requests?
Double check that you typed IsAuthenticatedOrReadOnly (not IsAuthenticated). The OrReadOnly suffix is what allows unauthenticated GET requests through.
help me fix my DRF permission_classes so unauthenticated GET requests are allowed
There is one more view to update. The MovieViewSet also needs permissions and a perform_create method that sets the movie's creator automatically.
- Find your MovieViewSet class and replace it with:
class MovieViewSet(viewsets.ModelViewSet):
queryset = Movie.objects.all()
serializer_class = MovieSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]
def perform_create(self, serializer):
# Automatically set the creator to the authenticated user
serializer.save(creator=self.request.user)
Why perform_create?
When a ViewSet calls serializer.save(), you can inject extra fields by overriding perform_create. This ensures the creator field is always set to the currently authenticated user, so no one can spoof ownership.
- Save reviews/views.py.
- Open http://127.0.0.1:8000/api/movies/ in your browser to confirm the movie list loads without a token.
✔️ Awesome, I've got everything!
Great. Double check you saved both reviews/permissions.py and reviews/views.py.
ⓧ I'd like to double check the full code
Your reviews/permissions.py should contain the full IsOwnerOrReadOnly class you created above. Scroll up and confirm it matches.
Your reviews/views.py should have these pieces in order:
- Imports including from .permissions import IsOwnerOrReadOnly.
- GenreListCreate and GenreDetail each with permission_classes = [permissions.IsAuthenticatedOrReadOnly].
- ReviewListCreate with permission_classes = [permissions.IsAuthenticatedOrReadOnly].
- ReviewDetail with permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly].
- MovieViewSet with permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly] and a perform_create method.
Test the auth flow end-to-end
Your permissions are in place. Time to prove the full JWT flow works: obtain a token, use it to create data, and confirm that editing another user's resource is blocked.
- First, create a second test user so you can test cross-user permissions. Open a new terminal tab and run:
python manage.py createsuperuser --username reviewer --email reviewer@test.com
What credentials should I use?
Enter any password you like when prompted. This user exists purely for testing. You now have two users: your original superuser and this new reviewer account.
- Obtain a JWT token for your original superuser by running:
curl -X POST http://127.0.0.1:8000/api/token/ \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "your-password-here"}'
You get back a JSON response with access and refresh tokens. Copy the access token value.
- Use the access token to create a review as your superuser by running:
curl -X POST http://127.0.0.1:8000/api/reviews/ \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-access-token-here" \
-d '{"movie": 1, "rating": 5, "body": "Excellent film!"}'
You should see a 201 Created response with the review data, including your username in the author field.
- Now obtain a token for the reviewer user by running:
curl -X POST http://127.0.0.1:8000/api/token/ \
-H "Content-Type: application/json" \
-d '{"username": "reviewer", "password": "your-password-here"}'
What do you think will happen if you try to edit the review you just created, using the reviewer user's token?
- Try to edit the first user's review as the reviewer user by running:
curl -X PUT http://127.0.0.1:8000/api/reviews/1/ \
-H "Content-Type: application/json" \
-H "Authorization: Bearer reviewer-access-token-here" \
-d '{"movie": 1, "rating": 1, "body": "Changed!"}'
You get a 403 Forbidden response with the message You do not have permission to perform this action. because the reviewer user does not own that review. Your IsOwnerOrReadOnly permission class blocked the request.
- Finally, confirm that token refresh works by running:
curl -X POST http://127.0.0.1:8000/api/token/refresh/ \
-H "Content-Type: application/json" \
-d '{"refresh": "your-refresh-token-here"}'
You get back a fresh access token. This is how clients stay authenticated after their short-lived access token expires.
- Confirm unauthenticated reads still work by running:
curl http://127.0.0.1:8000/api/movies/
You should see the full movie list returned with a 200 status. No token required for reading.
Getting a 401 when you expected 200 or 201?
Check that you copied the full access token string (it is long). Make sure the Authorization header uses Bearer (capital B) followed by a space and then the token.
Access tokens expire after 30 minutes. If yours has expired, use the refresh endpoint to get a new one.
help me debug why my JWT token is not authenticating my DRF API requests
Your API is now secured with JWT authentication and object-level permissions. Authenticated users can read and create, but only owners can edit or delete. Next up, you will add rate limiting and a writable nested serializer that creates a movie with genres in a single request.
Add Throttling and a Writable Nested Serializer
Your API is locked down with JWT authentication and owner-only permissions. Two powerful features are already configured but untested: rate limiting and the writable nested serializer.
Rate limiting protects your API from abuse by capping how many requests a client can make. The writable nested serializer lets API consumers create a movie with genre assignments in a single POST instead of multiple round-trips. In this step, you will trigger both features and see them work.
In this step, get ready to:
- Trigger rate limiting and observe a 429 Too Many Requests response.
- Create a movie with genre assignments in a single POST request.
- Confirm through-model records exist in the database.
Trigger rate limiting by lowering the throttle rate
Your settings.py already has throttle rates set to 'user': '200/hour'. Making 200 requests to see throttling in action is impractical. Instead, you will temporarily lower the rate to trigger it quickly.
- In movieapi/settings.py, find this line inside DEFAULT_THROTTLE_RATES:
- Find this line:
'user': '200/hour',
- Replace '200/hour' with '5/minute', so the line now looks like this:
'user': '5/minute',
- Save settings.py.
- Restart your development server by pressing Ctrl+C in the terminal and running:
python manage.py runserver
Now you have a 5 requests per minute limit for authenticated users. Time to hit it.
- Get a fresh access token by running this command (replace your-password-here with your superuser password):
curl -X POST http://127.0.0.1:8000/api/token/ \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "your-password-here"}'
- Copy the access token from the response.
- Make 6 rapid requests to the genres endpoint by running this loop (paste your access token in place of your-access-token):
for i in {1..6}; do
echo "Request $i:"
curl -s -o /dev/null -w "%{http_code}\n" \
http://127.0.0.1:8000/api/genres/ \
-H "Authorization: Bearer your-access-token"
done
What does this loop do?
The loop sends 6 GET requests in rapid succession. The -w "%{http_code}" flag tells curl to print only the HTTP status code for each response. You will see the status code change once you exceed 5 requests.
Before you run this, what do you think the 6th request's status code will be?
The first 5 requests return 200. The 6th returns 429 with a Retry-After header telling you how many seconds to wait.
See the full throttled response
To see the response body and headers (not just the status code), run a single request without the -s -o /dev/null -w flags after being throttled. You will see a JSON body containing the throttle message.
Still seeing 200 on all 6 requests?
Make sure you saved settings.py and restarted the server. Django does not pick up settings changes without a restart.
Check that the rate is '5/minute' (not '5/hour'). With '5/hour' you still only get 5, but the counter may not have reset from a prior run.
If testing again, wait 60 seconds for the per-minute window to reset. Why am I not seeing a 429 response?
Restore throttle rates and test the writable nested serializer
Throttling works. Now restore the production rates so you do not accidentally throttle yourself during the next test.
- In movieapi/settings.py, find the line you just changed:
'user': '5/minute',
- Replace '5/minute' with '200/hour', so it returns to:
'user': '200/hour',
- Save settings.py and restart the server.
What does the writable nested serializer do?
Your MovieSerializer already has a create() override that pops genre_assignments from the incoming data, creates the Movie, then loops through each assignment to create MovieGenre records. This lets API consumers attach genres to a movie in a single request.
Without this override, DRF's default ModelSerializer.create() does not support writable nested fields. It would raise an error if you tried to pass nested data.
Time to test it. You will POST a new movie with genre assignments included in the request body.
- First, confirm which genre IDs exist by running:
curl http://127.0.0.1:8000/api/genres/
Note the id values in the response. You will use them in the next request.
- Create a movie with inline genre assignments by running this command (replace your-access-token with your token, and adjust genre IDs if yours differ):
curl -X POST http://127.0.0.1:8000/api/movies/ \
-H "Authorization: Bearer your-access-token" \
-H "Content-Type: application/json" \
-d '{
"title": "The Matrix",
"release_year": 1999,
"description": "A computer hacker discovers reality.",
"genre_assignments": [
{"genre_id": 1, "relevance": "primary"},
{"genre_id": 2, "relevance": "secondary"}
]
}'
What do you think the response will contain? Will it show the genres nested inside the movie?
The response returns the newly created movie with its genres and reviews nested in the JSON. The genres array shows each genre object with its relevance value from the through model.
Why does the response show genres but the input used genre_assignments?
The genre_assignments field is write_only=True. It accepts data on input but never appears in output. The genres field is read_only=True. It appears in output but is ignored on input. This separation keeps the API's read and write contracts clean.
Confirm through-model records in the shell
- Open the Django shell by running:
python manage.py shell
- Query the MovieGenre through-model records by running:
from reviews.models import MovieGenre
# List all through-model records
for mg in MovieGenre.objects.all():
print(f"{mg.movie.title} - {mg.genre.name} ({mg.relevance})")
What should I see?
You should see output like The Matrix - Action (primary) and The Matrix - Comedy (secondary) (genre names depend on what you created earlier). Each line represents a row in the MovieGenre table that stores the relationship AND its metadata.
- Exit the shell by typing exit().
Seeing an empty list or an error?
If MovieGenre.objects.all() returns nothing, your POST may have failed silently. Check that the genre_id values in your request match actual genre IDs from /api/genres/.
If you see a 401 error on the POST, your access token may have expired. Get a fresh token from /api/token/ and try again. Help me debug my writable nested serializer test.
Your API now demonstrates three production patterns: JWT auth gates access, object permissions restrict edits to owners, and throttling prevents abuse. On top of that, the writable nested serializer proves that DRF can handle complex create operations in a single request. Next up, a Secret Mission that adds aggregated statistics to your movie endpoints.
Secret mission
Aggregated Movie Statistics Endpoint
Your API serves raw data, but API consumers often want computed insights. Can you add a custom endpoint that returns average ratings, review counts, and a full rating distribution for any movie, all powered by Django ORM aggregation?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with no cloud resources, so there are no ongoing costs.
Resources you used:
- Project directory containing manage.py, movieapi/, and reviews/.
- Virtual environment (venv/ folder) with Django, DRF, and simplejwt installed.
- SQLite database file (db.sqlite3) containing your movies, reviews, genres, and user accounts.
Keep everything running
No action needed. Choose this if you are still actively building or plan to continue with Project 2 (query optimization and tests).
- Your project directory stays on your Desktop with all files intact.
- The virtual environment and database remain ready to use. Reactivate the venv with source venv/bin/activate (macOS/Linux) or venv\Scripts\activate (Windows) whenever you return.
- The dev server is not running unless you start it, so nothing consumes resources in the background.
Pause - I'll come back to this later
Shut down the development server if it is still running to free up the terminal.
- Press Ctrl+C in the terminal where the dev server is running to stop it.
- Deactivate your virtual environment by running:
deactivate
- Your project files, database, and virtual environment all remain on disk. When you come back, reactivate the venv and run python manage.py runserver to pick up where you left off.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your code, database, and virtual environment permanently.
- Stop the dev server if it is still running by pressing Ctrl+C.
- Deactivate the virtual environment by running:
deactivate
- Navigate to your Desktop and delete the project folder by running:
macOS
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]
Windows
cd ~\Desktop
rmdir /s /q [[PROJECT_FOLDER="your project folder name"]]
- Confirm the folder is gone by running ls (macOS) or dir (Windows). Your project folder should no longer appear in the listing.
Nice Work!
Nice Work!
You just built a production-grade Movie Review API using Django REST Framework. This API handles multi-model relationships, JWT security, object-level permissions, rate limiting, and writable nested serializers.
You've learned how to:
- Build a multi-model API with ForeignKey and ManyToMany (through model) relationships, served through three distinct view styles side-by-side: raw APIView for reviews, generic class-based views for genres, and a ModelViewSet with DefaultRouter for movies.
- Secure endpoints with JWT authentication (access and refresh tokens) and enforce owner-only writes using a custom IsOwnerOrReadOnly permission class with has_object_permission.
- Apply throttling to protect your API from abuse, and build a writable nested serializer that creates a movie with genre assignments through the through model in a single POST request.
- Secret Mission: added a custom @action endpoint returning aggregated movie statistics using Django ORM Avg and Count annotations.
Ready to quiz yourself?