Build a Team Task Manager API

Build a JWT-secured REST API with custom permissions and Swagger docs.

Introduction

30 Second Summary

Every team tool you use at work has the same backbone: an API that handles who can log in, who can edit what, and how data flows between the frontend and the database. Building one from scratch is the fastest way to prove you understand production backend architecture.

In this project, you will build a Team Task Manager API using Django REST Framework. You will implement JWT authentication, custom permissions, filtered querysets, and auto-generated OpenAPI documentation.

What You'll Build

When you finish, you can open Swagger UI in your browser, authenticate with a token, create a project, assign tasks with priorities and deadlines, and watch the entire API contract update live in the interactive docs.

By the end of this project, you'll have:

  • A JWT-secured API where you can register a user, receive an access token, and authenticate every request without server-side session storage.
  • Full CRUD endpoints for projects and tasks that you can filter by status, priority, or assignee, search by title, and paginate through results.
  • A custom permission system that blocks you from editing another user's project and returns a clear 403 Forbidden response.
  • Interactive Swagger UI documentation auto-generated from your code that anyone can browse to understand every endpoint, parameter, and response schema.
  • Secret Mission: Add a task comments system with nested serializers and object-level permissions.

Are there any prerequisites?

This project assumes working knowledge of Django (models, views, URLs, migrations). You should already have Python 3.14 and MySQL installed on your machine.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters for your job search.

Set Up the Project and Install Dependencies

Every Django REST API starts with a project structure that separates concerns cleanly. In production codebases, teams organize logic by domain (like accounts for users and tasks for core features) rather than putting everything in one app.

In this step, you will create a virtual environment, install all the packages your API needs, scaffold the Django project with two domain apps, and configure your settings so the development server boots cleanly.

In this step, get ready to:
  • Create a virtual environment and install Django, DRF, and supporting packages.
  • Scaffold the Django project with two domain-driven apps.
  • Configure settings for MySQL, JWT auth, filtering, and API documentation.
Create virtual environment and install packages

A virtual environment isolates your project's dependencies from other Python projects on your machine. You will create one and then install all the packages the API needs.

  • Open a terminal inside VS Code by pressing Ctrl+` (macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir task-manager-api
cd task-manager-api
  • Create a virtual environment by running:
python -m venv venv
  • Activate the virtual environment by running the command for your operating system:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

  • Install all project dependencies by running:
pip install django==5.2.15 djangorestframework==3.17.1 djangorestframework-simplejwt==5.5.1 django-filter==26.1 drf-spectacular==0.30.0 mysqlclient==2.2.8

What do these packages do?

  • django is the web framework that handles routing, models, and project structure.
  • djangorestframework adds serializers, viewsets, and API-specific tooling on top of Django.
  • djangorestframework-simplejwt provides JSON Web Token authentication for stateless API access.
  • django-filter enables query-parameter filtering on API endpoints.
  • drf-spectacular auto-generates OpenAPI 3.0 documentation with Swagger UI.
  • mysqlclient is the Python database adapter that connects Django to MySQL.

You should see output ending with Successfully installed followed by a list of all the packages.

mysqlclient failed to install?

On Windows, if you see a build error mentioning a missing C library, download and install the MariaDB C Connector from https://mariadb.com/downloads/#connectors then retry the pip install command.

On macOS, run brew install mysql-client pkg-config first, then retry.

help me fix a mysqlclient installation error on my machine

Scaffold the Django project with domain-driven apps

Production Django projects separate logic into domain apps rather than putting all models and views into a single app. You will create a taskmanager project with two apps: accounts for user management and tasks for the core domain.

  • Scaffold the Django project in the current folder by running:
django-admin startproject taskmanager .

What does the dot at the end do?

The . tells Django to create the project in the current directory instead of nesting it inside another folder. This puts manage.py at the root of your task-manager-api folder.

  • Create the two domain apps by running:
python manage.py startapp accounts
python manage.py startapp tasks
  • Confirm your project structure by running:
ls

You should see accounts, manage.py, taskmanager, tasks, and venv in the output.

Why two separate apps?

Structuring by domain (accounts handles users, tasks handles the core business logic) is the pattern hiring managers look for. It keeps each app focused on a single responsibility and makes the codebase easier to navigate as it grows.

Configure settings and verify the dev server

The scaffolded settings.py uses SQLite by default and knows nothing about your installed packages. You need to register the new apps, point Django at your MySQL database, and configure Django REST Framework defaults.

  • Create the MySQL database your project will use by running:
mysql -u root -p -e "CREATE DATABASE taskmanager_db;"

Enter your MySQL root password when prompted. You should see no output if the command succeeds.

Access denied or command not found?

If you see Access denied, double-check your MySQL root password. If mysql is not found, make sure MySQL's bin directory is in your system PATH.

help me connect to MySQL from my terminal

  • In VS Code, open the file taskmanager/settings.py from the Explorer sidebar.
  • Replace everything from the top of the file through the end of INSTALLED_APPS with the following code:
import os
from pathlib import Path
from datetime import timedelta

BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = "django-insecure-change-this-in-production"

DEBUG = True

ALLOWED_HOSTS = []

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "rest_framework",
    "django_filters",
    "drf_spectacular",
    "accounts",
    "tasks",
]

What changed from the scaffold?

  • import os and from datetime import timedelta are new imports you will need later for JWT token lifetimes.
  • The last five entries in INSTALLED_APPS register DRF, django-filter, drf-spectacular, and your two domain apps with Django.
  • Scroll down to the DATABASES section. Replace the entire DATABASES dictionary with this MySQL configuration:
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.mysql",
        "NAME": "taskmanager_db",
        "USER": "root",
        "PASSWORD": "your-mysql-password-here",
        "HOST": "127.0.0.1",
        "PORT": "3306",
    }
}

What does this configure?

This points Django at the taskmanager_db MySQL database you just created. Replace your-mysql-password-here with your actual MySQL root password.

  • Save the file and run this command to verify Django can connect to your database:
python manage.py check

You should see System check identified no issues (0 silenced). in the output.

Seeing a database connection error?

Make sure you replaced your-mysql-password-here with your actual MySQL root password in settings.py. Also confirm the taskmanager_db database exists by running mysql -u root -p -e "SHOW DATABASES;" in your terminal.

help me fix a Django database connection error with MySQL

  • Now scroll to the very bottom of settings.py (below the DEFAULT_AUTO_FIELD line) and paste the following configuration:
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": (
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    ),
    "DEFAULT_PERMISSION_CLASSES": (
        "rest_framework.permissions.IsAuthenticated",
    ),
    "DEFAULT_FILTER_BACKENDS": (
        "django_filters.rest_framework.DjangoFilterBackend",
        "rest_framework.filters.SearchFilter",
        "rest_framework.filters.OrderingFilter",
    ),
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 10,
    "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}

What does REST_FRAMEWORK configure?

  • DEFAULT_AUTHENTICATION_CLASSES tells DRF to use JWT tokens for all requests.
  • DEFAULT_PERMISSION_CLASSES requires authentication by default on every endpoint.
  • DEFAULT_FILTER_BACKENDS enables filtering, search, and ordering on list endpoints.
  • PAGE_SIZE caps list responses at 10 items per page.
  • DEFAULT_SCHEMA_CLASS tells DRF to use drf-spectacular for auto-generating OpenAPI documentation.
  • Directly below the REST_FRAMEWORK dictionary, paste the JWT and documentation settings:
SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=30),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=7),
}

SPECTACULAR_SETTINGS = {
    "TITLE": "Team Task Manager API",
    "DESCRIPTION": "A production-ready API for managing projects and tasks across teams.",
    "VERSION": "1.0.0",
    "SERVE_INCLUDE_SCHEMA": False,
}

What do these settings control?

SIMPLE_JWT sets access tokens to expire after 30 minutes and refresh tokens after 7 days. Short-lived access tokens limit the damage if one is leaked.

SPECTACULAR_SETTINGS defines metadata for your auto-generated Swagger docs. Anyone browsing your API docs will see this title and description.

  • Save taskmanager/settings.py.

Before you run the server, what do you think will happen when you visit the root URL? Will you see an error, or a success page?

  • Start the development server by running:
python manage.py runserver

Open http://127.0.0.1:8000/ in your browser. You should see the Django "Congratulations!" page with a rocket taking off.

What about the unapplied migrations warning?

Your terminal shows a warning about unapplied migrations. This is expected. You will run migrations in the next step after defining your models.

Server won't start?

Check that your virtual environment is active (you should see (venv) in your prompt). Make sure your MySQL password in settings.py is correct and the taskmanager_db database exists.

help me fix a Django runserver error

  • Stop the server by pressing Ctrl+C in the terminal.

✔️ Awesome, I've got everything!

Great. Double check you have saved taskmanager/settings.py and that the server ran without import errors.

ⓧ I'd like to double check the full code

Scroll up and confirm your taskmanager/settings.py contains each of these sections in order:

  • Imports (os, Path, timedelta) and BASE_DIR, SECRET_KEY, DEBUG, ALLOWED_HOSTS.
  • INSTALLED_APPS with the 6 default Django apps plus rest_framework, django_filters, drf_spectacular, accounts, tasks.
  • MIDDLEWARE, ROOT_URLCONF, TEMPLATES, WSGI_APPLICATION (these stay the same as the scaffold default).
  • DATABASES pointing to taskmanager_db on MySQL.
  • AUTH_PASSWORD_VALIDATORS, LANGUAGE_CODE, TIME_ZONE, STATIC_URL, DEFAULT_AUTO_FIELD (stay the same as scaffold default).
  • REST_FRAMEWORK dict with JWT auth, IsAuthenticated, filter backends, pagination, and AutoSchema.
  • SIMPLE_JWT with 30-minute access and 7-day refresh lifetimes.
  • SPECTACULAR_SETTINGS with title, description, and version.

Your project skeleton is in place and Django is running against MySQL. Next up, you will define the Project and Task models and see your first API response.

Build the Data Models and Wire Up Basic Routes

Your Django project is scaffolded and the dev server runs. But an API without data models is like a warehouse with no shelves.

In this step, you will define the core domain models (Project and Task), wire up basic ViewSets so Django REST Framework can serve them, and connect everything through a router. When you test the endpoint at the end, you will hit a 401 Unauthorized. That is intentional. It proves the API exists but needs authentication, which is exactly what Step 3 solves.

In this step, get ready to:
  • Define Project and Task models with relationships, choices, and timestamps.
  • Create minimal ViewSets and register them on a DRF router at /api/.
  • Run migrations and confirm the API responds (with a 401 that sets up the next step).
Define the Project model

Every task in the system belongs to a project, and every project belongs to a user. The Project model captures this ownership relationship.

  • Open tasks/models.py in VS Code and replace its contents with the following code:
from django.db import models
from django.contrib.auth.models import User


class Project(models.Model):
    # Each project has a name, optional description, and an owner
    name = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name="owned_projects")
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.name

What does this code do?

This defines the Project model with four fields:

  • name is a short text field (max 200 characters) for the project title.
  • description is an optional long text field (blank=True means it can be left empty).
  • owner links each project to the Django User who created it. If that user is deleted, all their projects are deleted too (CASCADE).
  • created_at is automatically set to the current time when a project is created.
Add the Task model below Project

Tasks are the core unit of work. Each task belongs to a project, can be assigned to a user, and carries a priority and status.

  • In tasks/models.py, add the following Task model below the Project class:
class Task(models.Model):
    class Priority(models.TextChoices):
        LOW = "low", "Low"
        MEDIUM = "medium", "Medium"
        HIGH = "high", "High"

    class Status(models.TextChoices):
        TODO = "todo", "To Do"
        IN_PROGRESS = "in_progress", "In Progress"
        DONE = "done", "Done"

    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="tasks")
    assignee = models.ForeignKey(
        User, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_tasks"
    )
    priority = models.CharField(max_length=10, choices=Priority.choices, default=Priority.MEDIUM)
    status = models.CharField(max_length=20, choices=Status.choices, default=Status.TODO)
    due_date = models.DateField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.title

What does this code do?

The Task model uses two inner classes (Priority and Status) to define allowed choices. Django enforces these at the database level.

  • project links each task to its parent Project. Deleting a project cascades to delete all its tasks.
  • assignee is optional (null=True, blank=True). If the assigned user is deleted, the field becomes NULL instead of deleting the task.
  • priority defaults to MEDIUM and status defaults to TODO, so new tasks start in a sensible state.
  • Save tasks/models.py.

Before you run this, what output do you expect Django to produce when it detects new models?

  • Verify Django detects your models by running this command:
python manage.py makemigrations

You should see output like:

Migrations for 'tasks':
  tasks/migrations/0001_initial.py
    - Create model Project
    - Create model Task

Seeing "No changes detected"?

  • Make sure you saved tasks/models.py before running the command.
  • Check that "tasks" appears in INSTALLED_APPS inside taskmanager/settings.py.
  • Confirm the class names are Project and Task (capitalized) and they inherit from models.Model.

Help me debug why makemigrations is not detecting my models.

Register models in the admin

Registering models in Django's admin gives you a quick way to inspect and manage data directly in the browser later.

  • Open tasks/admin.py and replace its contents with:
from django.contrib import admin
from .models import Project, Task

admin.site.register(Project)
admin.site.register(Task)

What does this code do?

admin.site.register() tells Django to show these models in the admin panel at /admin/. You will not use the admin panel in this project, but registering models is a production best practice for debugging.

  • Save tasks/admin.py.

✔️ Awesome, I've got everything!

Great. Make sure both tasks/models.py and tasks/admin.py are saved.

ⓧ I'd like to double check the full code

Your tasks/models.py should have two sections in this order:

  • Imports (from django.db import models and from django.contrib.auth.models import User) at the top.
  • The Project class with name, description, owner, and created_at fields.
  • The Task class with inner Priority and Status choice classes, plus all seven fields (title, description, project, assignee, priority, status, due_date, created_at).

Your tasks/admin.py should be:

from django.contrib import admin
from .models import Project, Task

admin.site.register(Project)
admin.site.register(Task)
Create minimal ViewSets and wire up the router

A ViewSet bundles all CRUD operations (list, create, retrieve, update, delete) into a single class. Combined with a DefaultRouter, DRF auto-generates all the URL patterns for you.

  • Create a new file tasks/views.py (or open the existing one) and replace its contents with:
from rest_framework import viewsets, serializers
from .models import Project, Task


# Minimal serializers (will be replaced with proper read/write split later)
class ProjectSerializer(serializers.ModelSerializer):
    class Meta:
        model = Project
        fields = "__all__"


class TaskSerializer(serializers.ModelSerializer):
    class Meta:
        model = Task
        fields = "__all__"


class ProjectViewSet(viewsets.ModelViewSet):
    queryset = Project.objects.all()
    serializer_class = ProjectSerializer


class TaskViewSet(viewsets.ModelViewSet):
    queryset = Task.objects.all()
    serializer_class = TaskSerializer

What does this code do?

Each ModelSerializer automatically generates JSON fields from the model. Using fields = "__all__" exposes every field. This is a quick starting point you will replace with secure read/write serializers in Step 4.

Each ModelViewSet provides list, create, retrieve, update, and destroy actions out of the box. You only need to specify the queryset and serializer class.

  • Save tasks/views.py.
  • Now create tasks/urls.py and add the router configuration:
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ProjectViewSet, TaskViewSet

# The router auto-generates URL patterns for each ViewSet
router = DefaultRouter()
router.register(r"projects", ProjectViewSet)
router.register(r"tasks", TaskViewSet)

urlpatterns = [
    path("", include(router.urls)),
]

What does the router do?

DefaultRouter maps each registered ViewSet to a set of URLs automatically. After this configuration, DRF creates endpoints like /api/projects/, /api/projects/{id}/, /api/tasks/, and /api/tasks/{id}/ without you writing each URL by hand.

  • Save tasks/urls.py.
Connect the app URLs to the project

The task app's URLs exist, but the main project does not know about them yet. You also need a placeholder for the accounts app to avoid an import error.

  • Create a new file accounts/urls.py with an empty URL list (this prevents an import error and will be filled in Step 3):
from django.urls import path

urlpatterns = []
  • Now open taskmanager/urls.py and replace its contents with the full URL configuration:
from django.contrib import admin
from django.urls import path, include
from drf_spectacular.views import (
    SpectacularAPIView,
    SpectacularSwaggerView,
    SpectacularRedocView,
)

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include("tasks.urls")),
    path("api/auth/", include("accounts.urls")),
    path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
    path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
    path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
]

What does this URL configuration do?

  • path("api/", include("tasks.urls")) mounts all task and project endpoints under /api/.
  • path("api/auth/", include("accounts.urls")) reserves /api/auth/ for the authentication endpoints you will build in Step 3.
  • The three drf_spectacular paths set up auto-generated API documentation (Swagger UI and ReDoc) that you will explore in Step 5.
  • Save both accounts/urls.py and taskmanager/urls.py.

✔️ Awesome, I've got everything!

Great. Make sure all four files are saved: tasks/views.py, tasks/urls.py, accounts/urls.py, and taskmanager/urls.py.

ⓧ I'd like to double check the full code

Here is tasks/urls.py:

from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ProjectViewSet, TaskViewSet

router = DefaultRouter()
router.register(r"projects", ProjectViewSet)
router.register(r"tasks", TaskViewSet)

urlpatterns = [
    path("", include(router.urls)),
]

Here is taskmanager/urls.py:

from django.contrib import admin
from django.urls import path, include
from drf_spectacular.views import (
    SpectacularAPIView,
    SpectacularSwaggerView,
    SpectacularRedocView,
)

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include("tasks.urls")),
    path("api/auth/", include("accounts.urls")),
    path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
    path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
    path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
]
Create the database and run migrations

Your settings.py points to a MySQL database called taskmanager_db. That database does not exist yet, so Django cannot apply migrations until you create it.

  • Create the MySQL database by running this command:
mysql -u root -p -e "CREATE DATABASE taskmanager_db;"

What does this command do?

This connects to your local MySQL server as the root user and creates a new empty database called taskmanager_db. The -p flag prompts you for your MySQL password.

  • Apply the migrations to create the database tables by running:
python manage.py migrate

You should see a list of migrations being applied, ending with lines like Applying tasks.0001_initial... OK.

Seeing a database connection error?

  • Make sure MySQL is running on your machine. Check with mysql -u root -p -e "SELECT 1;".
  • Verify the PASSWORD value in taskmanager/settings.py under DATABASES matches your actual MySQL root password.
  • Confirm the database exists by running mysql -u root -p -e "SHOW DATABASES;" and checking for taskmanager_db in the list.

Help me fix a database connection error when running Django migrations.

Test the API endpoint

Everything is wired up. The models are migrated, the ViewSets are registered, and the URLs are connected. Time to see if the API responds.

Before you run the server, what do you think will happen when you visit /api/projects/? Remember that your settings.py sets JWTAuthentication as the default authentication class and IsAuthenticated as the default permission.

  • Start the development server by running:
python manage.py runserver
  • Open your browser and visit http://127.0.0.1:8000/api/projects/.

You will see a 401 Unauthorized response. The API is alive and routing correctly, but it rejects your request because you have not provided a JWT token. There are no token endpoints yet, so there is no way to authenticate. That is the exact problem Step 3 solves.

Why is 401 a good sign?

A 401 means the URL resolved, the ViewSet responded, and the permission system is working. If you saw a 404 instead, the URL wiring would be broken. A 401 confirms the API layer is correct and only authentication is missing.

Seeing a 404 instead of 401?

  • Check that taskmanager/urls.py has path("api/", include("tasks.urls")).
  • Verify tasks/urls.py registers both projects and tasks on the router.
  • Make sure you are visiting http://127.0.0.1:8000/api/projects/ with the trailing slash.

Help me debug why my DRF API is returning 404 instead of reaching the ViewSet.

Your models are defined, the router is wired, and the API is responding. The 401 proves authentication is enforced but unreachable. Next up, you will build JWT token endpoints so users can actually log in and access the API.

Implement JWT Authentication

In the last step, you hit a 401 Unauthorized when trying to access /api/projects/. The API exists and responds, but it rejects every request because there is no way for users to prove who they are.

You need three things to fix this: a registration endpoint so new users can sign up, a token endpoint so existing users can authenticate, and the ability to pass that token with every subsequent request. JWT (JSON Web Tokens) is the industry standard for API authentication because tokens are stateless and work with any client.

In this step, get ready to:
  • Build a user registration endpoint that creates accounts.
  • Add token endpoints that issue and refresh JWTs.
  • Test the full auth flow from sign-up to authenticated API access.
Create the registration serializer and view

Before users can get tokens, they need a way to create an account. You will build a RegisterSerializer that validates incoming registration data and a RegisterView that exposes it as an API endpoint.

  • Create a new file called accounts/serializers.py and add the following code:
from rest_framework import serializers
from django.contrib.auth.models import User


class RegisterSerializer(serializers.ModelSerializer):
    # write_only ensures the password is never returned in responses
    password = serializers.CharField(write_only=True, min_length=8)

    class Meta:
        model = User
        fields = ("id", "username", "email", "password")

    def create(self, validated_data):
        # create_user hashes the password automatically
        user = User.objects.create_user(
            username=validated_data["username"],
            email=validated_data.get("email", ""),
            password=validated_data["password"],
        )
        return user

What does this code do?

The RegisterSerializer inherits from ModelSerializer to automatically map to Django's built-in User model.

  • write_only=True on the password field means the password is accepted during registration but never exposed in API responses.
  • min_length=8 enforces a minimum password length at the serializer level.
  • create_user() hashes the password before storing it, so raw passwords never touch the database.
  • Save accounts/serializers.py.
  • Now open accounts/views.py and replace its contents with the following:
from rest_framework import generics
from rest_framework.permissions import AllowAny
from .serializers import RegisterSerializer


class RegisterView(generics.CreateAPIView):
    # CreateAPIView handles POST requests to create a new object
    serializer_class = RegisterSerializer
    # AllowAny lets unauthenticated users register
    permission_classes = [AllowAny]

What does this code do?

CreateAPIView is a DRF generic view that handles POST requests. It validates the request body against RegisterSerializer and calls create() automatically.

AllowAny overrides the project's default IsAuthenticated permission so that unauthenticated users can reach this endpoint. Without it, new users could never register.

  • Save accounts/views.py.
  • Confirm the server still starts without import errors by running:
python manage.py runserver

You should see the development server start at http://127.0.0.1:8000/ with no tracebacks.

Seeing an ImportError?

  • Check that accounts/serializers.py is in the accounts/ folder (not the project root or tasks/).
  • Confirm the import in views.py says from .serializers import RegisterSerializer (note the leading dot for a relative import).

Help me debug an ImportError in my accounts app.

  • Stop the server with Ctrl+C.
Wire up the authentication URLs

The serializer and view exist, but they are not reachable by any URL yet. You also need token endpoints from djangorestframework-simplejwt so that registered users can exchange credentials for a JWT pair.

  • Open accounts/urls.py and replace its contents with the following:
from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from .views import RegisterView

urlpatterns = [
    # POST /api/auth/register/ - create a new user account
    path("register/", RegisterView.as_view(), name="register"),
    # POST /api/auth/token/ - exchange credentials for access + refresh tokens
    path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
    # POST /api/auth/token/refresh/ - get a new access token using a refresh token
    path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
]

What does this code do?

  • register/ maps to your custom RegisterView so users can create accounts via POST.
  • token/ uses SimpleJWT's built-in TokenObtainPairView which accepts a username and password and returns an access token plus a refresh token.
  • token/refresh/ uses TokenRefreshView to issue a fresh access token when the original expires (every 30 minutes, as configured in your settings).

Why JWT instead of session authentication?

Session auth stores a session ID on the server and sends a cookie to the browser. This works fine for traditional Django templates, but breaks down for APIs consumed by mobile apps, single-page apps, or other services.

JWT tokens are self-contained. The client stores the token and sends it with every request. No server-side session storage needed, and any client that can set an HTTP header can authenticate.

  • Save accounts/urls.py.
  • Start the server to confirm the new routes load:
python manage.py runserver

The server should start without errors. The three new endpoints are now live.

Seeing a ModuleNotFoundError for simplejwt?

  • Make sure your virtual environment is activated (you should see (venv) in your terminal prompt).
  • Verify simplejwt is installed by running pip show djangorestframework-simplejwt. If nothing appears, re-run the install command from Step 1.

Help me fix a ModuleNotFoundError for rest_framework_simplejwt.

✔️ Awesome, I've got everything!

Great. Make sure you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is accounts/serializers.py:

from rest_framework import serializers
from django.contrib.auth.models import User


class RegisterSerializer(serializers.ModelSerializer):
    password = serializers.CharField(write_only=True, min_length=8)

    class Meta:
        model = User
        fields = ("id", "username", "email", "password")

    def create(self, validated_data):
        user = User.objects.create_user(
            username=validated_data["username"],
            email=validated_data.get("email", ""),
            password=validated_data["password"],
        )
        return user

Here is accounts/views.py:

from rest_framework import generics
from rest_framework.permissions import AllowAny
from .serializers import RegisterSerializer


class RegisterView(generics.CreateAPIView):
    serializer_class = RegisterSerializer
    permission_classes = [AllowAny]

Here is accounts/urls.py:

from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from .views import RegisterView

urlpatterns = [
    path("register/", RegisterView.as_view(), name="register"),
    path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
    path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
]
Test the full authentication flow

All the pieces are in place. You will now register a user, obtain a JWT token pair, and use the access token to make an authenticated request to the projects endpoint.

  • Make sure the dev server is running. If you stopped it, start it again:
python manage.py runserver
  • Open a second terminal (keep the server running in the first one).
  • Register a new user by running this command:

macOS

curl -X POST http://127.0.0.1:8000/api/auth/register/ -H "Content-Type: application/json" -d '{"username": "testuser", "password": "testpass123", "email": "test@example.com"}'

Windows

In PowerShell, use curl.exe (not curl, which is an alias for a different command):

curl.exe -X POST http://127.0.0.1:8000/api/auth/register/ -H "Content-Type: application/json" -d '{"username": "testuser", "password": "testpass123", "email": "test@example.com"}'

You should see a JSON response containing the new user's id, username, and email (but no password). This confirms registration works.

  • Now obtain a JWT token pair by posting the credentials you just registered:

macOS

curl -X POST http://127.0.0.1:8000/api/auth/token/ -H "Content-Type: application/json" -d '{"username": "testuser", "password": "testpass123"}'

Windows

curl.exe -X POST http://127.0.0.1:8000/api/auth/token/ -H "Content-Type: application/json" -d '{"username": "testuser", "password": "testpass123"}'

You should see a JSON response with two keys: access and refresh. The access token is what you send with every authenticated request.

Before you run the next command, think: what do you expect to get back from /api/projects/ now that you have a valid token?

  • Copy the access token value from the response (the long string after "access":).
  • Use it to make an authenticated request to the projects endpoint:

macOS

curl http://127.0.0.1:8000/api/projects/ -H "Authorization: Bearer your-access-token-here"

Windows

curl.exe http://127.0.0.1:8000/api/projects/ -H "Authorization: Bearer your-access-token-here"

Replace the placeholder token

Replace your-access-token-here with the actual token string you copied from the previous response. It will be a long encoded string starting with eyJ.

You should see a paginated response with an empty results list and a 200 status. The 401 is gone. Authentication is working.

Still seeing 401 Unauthorized?

  • Make sure you included the word Bearer (with a capital B) before the token, separated by a space.
  • Confirm you copied the full token string with no trailing spaces or line breaks.
  • Access tokens expire after 30 minutes. If you waited too long, re-run the /api/auth/token/ request to get a fresh one.

Help me debug a 401 error when using my JWT access token.

Your API now has a complete authentication system. Users can register, log in to receive tokens, and use those tokens to access protected endpoints. Next up, you will build proper serializers and custom permissions so that users can only modify their own projects.

Add Proper Serializers and Custom Permissions

Your API endpoints are live and JWT authentication gates every request. But there is a critical flaw lurking beneath the surface.

Right now, any authenticated user can edit any project. There is no ownership enforcement. In this step, you will prove that gap exists, then close it with proper serializer design and a custom permission class.

In this step, get ready to:
  • Expose the security hole by editing another user's project.
  • Replace basic serializers with separate read and write versions.
  • Add a custom permission class that restricts edits to owners only.
Expose the security gap

Before writing any fix, you need to see the problem with your own eyes. You will create a project owned by your existing user, then attempt to edit it as a different user.

  • Stop the running dev server by pressing Ctrl+C in the terminal.
  • Create test data by running this command:
python manage.py shell -c "from django.contrib.auth.models import User; from tasks.models import Project; user_a = User.objects.first(); Project.objects.create(name='Secret Plans', owner=user_a); User.objects.create_user('attacker', password='hackme123'); print('Done: project created, attacker user ready')"

What does this command do?

This one-liner opens a Django shell, grabs the first user (the one you registered in the previous step), creates a project owned by that user, and creates a second user called attacker. Now you have two users and one project to test with.

  • Start the server again by running:
python manage.py runserver
  • Open a second terminal in VS Code by clicking Terminal in the top menu bar, then New Terminal.
  • Activate your virtual environment in the new terminal by running:
venv\Scripts\activate

Now you will write a quick Python script to test the security gap. Before you run it, ask yourself: do you think the attacker will be able to rename another user's project?

  • Run this test script to attempt editing User A's project as the attacker:
python manage.py shell -c "
import requests
# Get a token for the attacker
resp = requests.post('http://127.0.0.1:8000/api/auth/token/', json={'username': 'attacker', 'password': 'hackme123'})
token = resp.json()['access']
# Try to rename User A's project
result = requests.patch('http://127.0.0.1:8000/api/projects/1/', json={'name': 'HIJACKED'}, headers={'Authorization': f'Bearer {token}'})
print(f'Status: {result.status_code}')
print(f'Response: {result.json()}')
"

You do not have the requests library installed

The test above uses the requests library which is not installed in your virtual environment. Use this alternative approach instead, which uses Django's test client directly:

  • Run this test using Django's built-in tools:
python manage.py shell -c "
from django.test import RequestFactory
from django.contrib.auth.models import User
from tasks.views import ProjectViewSet
from tasks.models import Project

factory = RequestFactory()
attacker = User.objects.get(username='attacker')
project = Project.objects.first()

request = factory.patch('/api/projects/1/', data={'name': 'HIJACKED'}, content_type='application/json')
request.user = attacker

view = ProjectViewSet.as_view({'patch': 'partial_update'})
response = view(request, pk=project.pk)
print(f'Status: {response.status_code}')
print(f'Project name is now: {Project.objects.first().name}')
"

You see a 200 status and the project name changed to HIJACKED. The attacker successfully renamed someone else's project. That is the gap you will now close.

Why is this a problem?

In a real application, users should only be able to modify their own resources. Without ownership checks, any logged-in user can edit or delete any record. This is one of the most common security flaws in junior-level API code.

  • Reset the project name back to its original value by running:
python manage.py shell -c "from tasks.models import Project; p = Project.objects.first(); p.name = 'Secret Plans'; p.save(); print('Reset done')"
Create separate read and write serializers

Production APIs use separate serializers for reading and writing. Read serializers expose friendly data like usernames. Write serializers accept only the fields a client should control.

  • Open tasks/serializers.py in VS Code and replace the entire contents with the Project serializers:
from rest_framework import serializers
from .models import Project, Task


class ProjectReadSerializer(serializers.ModelSerializer):
    # Show the owner's username instead of their numeric ID
    owner = serializers.StringRelatedField()
    # Count how many tasks belong to this project
    task_count = serializers.IntegerField(source="tasks.count", read_only=True)

    class Meta:
        model = Project
        fields = ("id", "name", "description", "owner", "task_count", "created_at")


class ProjectWriteSerializer(serializers.ModelSerializer):
    class Meta:
        model = Project
        # Only accept name and description from the client
        # Owner is set automatically in the view
        fields = ("id", "name", "description")

What does this code do?

You now have two serializers for the same model. ProjectReadSerializer exposes the owner's username and a task count. These are useful for display but should never be writable by a client.

ProjectWriteSerializer only accepts name and description. The owner gets set in the view's perform_create method, preventing anyone from assigning ownership to another user.

  • Below the Project serializers in the same file, add the Task serializers:
class TaskReadSerializer(serializers.ModelSerializer):
    # Expose the project name and assignee username for readability
    project_name = serializers.CharField(source="project.name", read_only=True)
    assignee_username = serializers.CharField(source="assignee.username", read_only=True, default=None)

    class Meta:
        model = Task
        fields = (
            "id",
            "title",
            "description",
            "project",
            "project_name",
            "assignee",
            "assignee_username",
            "priority",
            "status",
            "due_date",
            "created_at",
        )


class TaskWriteSerializer(serializers.ModelSerializer):
    class Meta:
        model = Task
        fields = ("id", "title", "description", "project", "assignee", "priority", "status", "due_date")

Why separate read and write serializers?

TaskReadSerializer includes derived fields like project_name and assignee_username so clients get human-readable data without extra API calls.

TaskWriteSerializer accepts foreign key IDs (project, assignee) for creating or updating. This split keeps your API responses rich while your inputs stay lean and validated.

  • Save tasks/serializers.py.

Seeing a syntax error?

Make sure each class definition is at the top level (no extra indentation). Check that the fields tuples end with a closing parenthesis and that each serializer class has its own class Meta block.

help me fix a syntax error in my DRF serializers file

Create a custom permission and wire up ViewSets

The serializers control what data flows in and out. Now you need a permission class that blocks writes unless the request comes from the object's owner.

  • Create a new file tasks/permissions.py and add the following permission class:
from rest_framework.permissions import BasePermission, SAFE_METHODS


class IsOwnerOrReadOnly(BasePermission):
    """
    Allow read access to any authenticated user.
    Write access only to the owner of the object.
    """

    def has_object_permission(self, request, view, obj):
        # GET, HEAD, OPTIONS are safe and allowed for everyone
        if request.method in SAFE_METHODS:
            return True
        # Only the owner can modify or delete
        return obj.owner == request.user

What does this code do?

SAFE_METHODS is a tuple containing GET, HEAD, and OPTIONS. Any authenticated user can read any project. But PUT, PATCH, and DELETE only succeed when the requesting user matches obj.owner.

DRF calls has_object_permission automatically on detail endpoints (retrieve, update, destroy). Returning False triggers a 403 Forbidden response.

  • Save tasks/permissions.py.
  • Create a new file tasks/filters.py and add this filter class (needed by the ViewSet you are about to write):
import django_filters
from .models import Task


class TaskFilter(django_filters.FilterSet):
    status = django_filters.CharFilter(field_name="status", lookup_expr="exact")
    priority = django_filters.CharFilter(field_name="priority", lookup_expr="exact")
    assignee = django_filters.NumberFilter(field_name="assignee__id")
    project = django_filters.NumberFilter(field_name="project__id")

    class Meta:
        model = Task
        fields = ["status", "priority", "assignee", "project"]

What does this code do?

This defines a FilterSet that lets clients filter tasks by status, priority, assignee, or project using query parameters. You will explore how filtering works in the next step. For now, the ViewSet import needs this file to exist.

  • Save tasks/filters.py.
  • Now replace the entire contents of tasks/views.py with the imports and ProjectViewSet:
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from .models import Project, Task
from .serializers import (
    ProjectReadSerializer,
    ProjectWriteSerializer,
    TaskReadSerializer,
    TaskWriteSerializer,
)
from .permissions import IsOwnerOrReadOnly
from .filters import TaskFilter


class ProjectViewSet(viewsets.ModelViewSet):
    queryset = Project.objects.all()
    permission_classes = [IsAuthenticated, IsOwnerOrReadOnly]

    def get_serializer_class(self):
        if self.action in ("list", "retrieve"):
            return ProjectReadSerializer
        return ProjectWriteSerializer

    def perform_create(self, serializer):
        # Automatically set the owner to the logged-in user
        serializer.save(owner=self.request.user)

What does this code do?

permission_classes now includes your custom IsOwnerOrReadOnly. DRF checks each permission in order. A request must pass both IsAuthenticated and IsOwnerOrReadOnly to succeed.

get_serializer_class returns the read serializer for list and retrieve actions, and the write serializer for create, update, and partial_update. perform_create sets owner automatically so clients cannot forge ownership.

  • Below ProjectViewSet in the same file, add the TaskViewSet:
class TaskViewSet(viewsets.ModelViewSet):
    queryset = Task.objects.select_related("project", "assignee").all()
    permission_classes = [IsAuthenticated]
    filterset_class = TaskFilter
    search_fields = ["title", "description"]
    ordering_fields = ["created_at", "due_date", "priority"]
    ordering = ["-created_at"]

    def get_serializer_class(self):
        if self.action in ("list", "retrieve"):
            return TaskReadSerializer
        return TaskWriteSerializer

What does this code do?

select_related eagerly loads the related project and assignee in a single database query, avoiding the N+1 problem when serializing task lists.

The filterset_class, search_fields, and ordering_fields attributes enable filtering, search, and ordering. You will test these features in the next step.

  • Save tasks/views.py.

Seeing an import error when the server restarts?

If the server reports it cannot import IsOwnerOrReadOnly or TaskFilter, confirm you created both tasks/permissions.py and tasks/filters.py in the tasks directory (not the project root).

help me fix an import error in my Django tasks app views

Now it is time to prove the fix works. Before you run the test, predict: what status code do you expect the attacker to receive now?

  • In your second terminal, run the same attack test again:
python manage.py shell -c "
from django.test import RequestFactory
from django.contrib.auth.models import User
from tasks.views import ProjectViewSet
from tasks.models import Project

factory = RequestFactory()
attacker = User.objects.get(username='attacker')
project = Project.objects.first()

request = factory.patch('/api/projects/1/', data={'name': 'HIJACKED'}, content_type='application/json')
request.user = attacker

view = ProjectViewSet.as_view({'patch': 'partial_update'})
response = view(request, pk=project.pk)
print(f'Status: {response.status_code}')
print(f'Project name is still: {Project.objects.first().name}')
"

You see Status: 403 and the project name remains Secret Plans. The attacker is blocked. Your custom permission is working.

Still seeing status 200?

Make sure you saved tasks/views.py with the updated permission_classes list. The IsOwnerOrReadOnly class must appear after IsAuthenticated in the list. Also confirm that tasks/permissions.py checks obj.owner == request.user, not obj.user.

help me debug why my IsOwnerOrReadOnly permission is not blocking unauthorized edits

✔️ Awesome, I've got everything!

Great. Confirm you saved all three files: tasks/serializers.py, tasks/permissions.py, and tasks/views.py.

ⓧ I'd like to double check the full code

Scroll up and confirm each of these chunks is present in order:

  • tasks/serializers.py: ProjectReadSerializer and ProjectWriteSerializer (first chunk), then TaskReadSerializer and TaskWriteSerializer (second chunk).
  • tasks/permissions.py: IsOwnerOrReadOnly class with has_object_permission method.
  • tasks/filters.py: TaskFilter class with four filter fields.
  • tasks/views.py: imports at the top, ProjectViewSet with IsOwnerOrReadOnly permission and perform_create, then TaskViewSet with filterset_class and search_fields.

Your API now has proper ownership enforcement. Any authenticated user can view projects, but only the owner can modify them. Next up, you will add filtering, search, and pagination so clients can query tasks efficiently, and generate interactive API documentation with Swagger UI.

Add Filtering, Pagination, and Swagger Docs

Your API handles authentication and enforces ownership permissions. Clients can create projects and tasks, but right now there is no way to narrow down results.

An API that forces every client to fetch the entire task list and sort locally is not production-ready. In this step, you will activate filtering, search, and ordering on your tasks endpoint, confirm pagination shapes the response correctly, and open auto-generated Swagger documentation that makes the whole API instantly demoable.

In this step, get ready to:
  • Test task filtering by status, priority, and assignee.
  • Verify paginated responses include proper metadata keys.
  • Open the interactive Swagger UI documentation at /api/docs/.
Confirm filtering and search on TaskViewSet

Your TaskViewSet already has filtering, search, and ordering wired up from the previous step. Let's look at what each attribute does.

  • In VS Code, open tasks/views.py and find the TaskViewSet class.
  • Confirm you see these four lines on the class:
filterset_class = TaskFilter
search_fields = ["title", "description"]
ordering_fields = ["created_at", "due_date", "priority"]
ordering = ["-created_at"]

What do these attributes do?

  • filterset_class = TaskFilter connects the django-filter FilterSet you defined in tasks/filters.py. Clients can now append ?status=todo or ?priority=high to narrow results.
  • search_fields enables DRF's SearchFilter. A ?search=deploy query param performs a case-insensitive partial match on both title and description.
  • ordering_fields lists which fields the client is allowed to sort by. ordering sets the default sort (newest first).

Time to test it. Make sure your dev server is running, then obtain a fresh access token.

  • Open a new terminal and get a token by running:
curl -X POST http://127.0.0.1:8000/api/auth/token/ -H "Content-Type: application/json" -d "{\"username\": \"testuser\", \"password\": \"testpass123\"}"
  • Copy the access value from the response.
  • Test the status filter by running this command (replace YOUR_TOKEN with the access token you copied):
curl http://127.0.0.1:8000/api/tasks/?status=todo -H "Authorization: Bearer YOUR_TOKEN"

You should see only tasks with "status": "todo" in the response. If you have no tasks yet, the results array will be empty, which is fine.

  • Test the search by running:
curl http://127.0.0.1:8000/api/tasks/?search=deploy -H "Authorization: Bearer YOUR_TOKEN"

This returns any task whose title or description contains "deploy". The filter and search are working.

Getting a 401 Unauthorized?

Your access token expires after 30 minutes. If it has expired, re-run the token request to get a fresh one.

Make sure the Authorization header says Bearer (capital B) followed by a space and the token. No quotes around the token value.

Why is my filter request returning 401?

Verify paginated responses

Pagination is already configured globally in settings.py. Every list endpoint now wraps results in a paginated envelope.

  • Open taskmanager/settings.py and find these two lines inside REST_FRAMEWORK:
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 10,

What does PageNumberPagination do?

Instead of returning a bare JSON array, every list response now returns an object with four keys: count (total items across all pages), next (URL to the next page or null), previous (URL to the previous page or null), and results (the current page of items).

This lets frontend clients build "Load More" buttons or page navigation without guessing how many items exist.

  • Confirm the shape by listing your tasks:
curl http://127.0.0.1:8000/api/tasks/ -H "Authorization: Bearer YOUR_TOKEN"

You should see a JSON object with count, next, previous, and results as top-level keys. The task objects live inside results.

Seeing a bare array instead of a paginated object?

Check that DEFAULT_PAGINATION_CLASS is set in your REST_FRAMEWORK dictionary. If PAGE_SIZE is missing, pagination silently disables.

Restart your dev server after changing settings.py. Django picks up setting changes on reload but curl hits the running process.

Why am I not seeing paginated results?

Open the Swagger UI documentation

Your taskmanager/urls.py already includes three drf-spectacular URL patterns that serve auto-generated OpenAPI documentation.

  • Open taskmanager/urls.py and confirm you see these three paths:
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),

What do these three endpoints provide?

  • /api/schema/ serves the raw OpenAPI JSON schema that describes every endpoint, parameter, and response type in your API.
  • /api/docs/ renders Swagger UI. This is an interactive page where you can try requests, see response formats, and explore authentication requirements.
  • /api/redoc/ renders ReDoc. This is a read-friendly documentation layout ideal for sharing with teammates.

Before you open the page, what do you expect to see? Think about which endpoints should appear and whether the JWT auth scheme will be documented.

  • Make sure your dev server is running.
  • Open your browser and navigate to http://127.0.0.1:8000/api/docs/.

You should see the Swagger UI page titled "Team Task Manager API" with all your endpoints listed. Each endpoint is expandable to show its HTTP method, URL path, parameters (including filter query params like status and priority), and response schemas. The JWT authentication scheme appears in the Authorize button at the top.

Seeing a blank page at /api/docs/?

A blank page usually means the Swagger UI CDN assets could not load. Check your internet connection. drf-spectacular loads the Swagger UI JavaScript from a CDN by default.

If you are using django-csp, its default policy blocks external scripts. For local development, you can temporarily remove django-csp from MIDDLEWARE or adjust your CSP rules.

Help me get Swagger UI loading at /api/docs/

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files and that Swagger UI is loading correctly at http://127.0.0.1:8000/api/docs/.

ⓧ I'd like to double check the full code

Confirm these are the key files for this step. Scroll up through your project and verify each one matches.

tasks/views.py should contain the complete TaskViewSet with filtering, search, and ordering attributes:

from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from .models import Project, Task
from .serializers import (
    ProjectReadSerializer,
    ProjectWriteSerializer,
    TaskReadSerializer,
    TaskWriteSerializer,
)
from .permissions import IsOwnerOrReadOnly
from .filters import TaskFilter
class ProjectViewSet(viewsets.ModelViewSet):
    queryset = Project.objects.all()
    permission_classes = [IsAuthenticated, IsOwnerOrReadOnly]

    def get_serializer_class(self):
        if self.action in ("list", "retrieve"):
            return ProjectReadSerializer
        return ProjectWriteSerializer

    def perform_create(self, serializer):
        serializer.save(owner=self.request.user)
class TaskViewSet(viewsets.ModelViewSet):
    queryset = Task.objects.select_related("project", "assignee").all()
    permission_classes = [IsAuthenticated]
    filterset_class = TaskFilter
    search_fields = ["title", "description"]
    ordering_fields = ["created_at", "due_date", "priority"]
    ordering = ["-created_at"]

    def get_serializer_class(self):
        if self.action in ("list", "retrieve"):
            return TaskReadSerializer
        return TaskWriteSerializer

taskmanager/urls.py should include the Swagger and ReDoc paths:

from django.contrib import admin
from django.urls import path, include
from drf_spectacular.views import (
    SpectacularAPIView,
    SpectacularSwaggerView,
    SpectacularRedocView,
)

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include("tasks.urls")),
    path("api/auth/", include("accounts.urls")),
    path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
    path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
    path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
]

Your API is now production-complete. Clients can filter, search, order, and paginate results. Anyone with the URL can browse the full API contract in Swagger UI without reading your source code. Next up, the Secret Mission adds nested comments with object-level permissions.

Secret mission

Add a Task Comments System

Your API handles projects and tasks, but real teams need discussion threads on their work. Can you add a nested comments resource with object-level permissions so only comment authors can edit or delete their own comments?

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 a MySQL database, so there are no ongoing cloud costs.

Resources you used:

  • Local MySQL database (taskmanager_db).
  • Project folder on your Desktop (includes the Python virtual environment and all installed packages).

Keep everything running

No action needed. Choose this if you are still actively building on your API or plan to add a frontend, write tests, or deploy it.

  • Your taskmanager_db database stays in MySQL and takes up minimal disk space.
  • Your project folder and virtual environment remain on your Desktop, ready for you to reactivate with venv\Scripts\activate.

Pause - I'll come back to this later

Shut down any running processes to free up memory. Your files and database stay intact for when you return.

  • Stop the Django development server if it is still running by pressing Ctrl+C in the terminal.
  • Deactivate the virtual environment by running deactivate in your terminal.
  • Close VS Code. When you come back, reopen the project folder, activate the virtual environment, and run the dev server again.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your database and project files.

  • Stop the Django development server if it is still running by pressing Ctrl+C.
  • Deactivate the virtual environment by running deactivate.
  • Drop the MySQL database by running this command:
mysql -u root -p -e "DROP DATABASE taskmanager_db;"

You will be prompted to enter your MySQL root password.

  • Delete the project folder from your Desktop. On macOS or Linux, run:
cd ~/Desktop
rm -rf taskmanager

On Windows, run:

cd ~\Desktop
rmdir /s /q taskmanager

Nice Work!

Nice Work!

Nice work! You just built a production-ready Team Task Manager API from scratch using Django REST Framework. This is the exact backend architecture that hiring managers look for when evaluating Django candidates.

You've learned how to:

  • Build a JWT-authenticated REST API with a domain-driven project structure, register/login endpoints, and token-based access control that works with any client.
  • Create custom permissions and separate read/write serializers that enforce ownership-based access control and keep your API surface clean and secure.
  • Add filtering, search, pagination, and auto-generated Swagger UI documentation so any developer can browse and understand your API contract instantly.
  • Secret Mission: Added a nested comments system with object-level permissions, proving you can handle resource relationships beyond simple foreign keys.

Ready to quiz yourself?