How to buildOctober 11, 202610 min read
Backend DevelopmentStep 8 of 13

Django: models, admin and views

In short

Django is a Python framework that ships with an ORM, database migrations, an admin panel, CSRF-protected forms, templates and a test runner. You describe a model as a Python class, makemigrations and migrate turn it into a table, and a single @admin.register line gives you a working interface for editing the data. This lesson builds a service catalogue with a public page, an inquiry form, a JSON view and seven tests on Django 6.1.

TutorialLevel: BeginnerDjangoPractice: ~1.5–3 h

After this you will be able to

  • Create a Django project and app, register the app in INSTALLED_APPS and explain the difference between the two
  • Describe models with fields, choices and ForeignKey, and create and apply migrations with makemigrations and migrate
  • Register models in the admin and configure list_display, list_filter, search_fields and prepopulated_fields
  • Serve pages through views and templates, accept a validated CSRF-protected form and cover it all with manage.py test

Verified with

Python 3.12Django 6.1SQLite 3.49

Checked against docs: October 11, 2026

Contents
  1. 01“Batteries included”: what Django gives you
  2. 02Project and app
  3. 03Models and migrations
  4. 04The admin: registering models and tuning the list
  5. 05Forms and validation
  6. 06URLs, views and templates
  7. 07Tests: manage.py test
  8. 08Common mistakes, production and exercises

“Batteries included”: what Django gives you

The general job of a backend — accepting requests, working with a database and sending responses — is covered in “What is a backend”. Django does that job with ready-made components for typical website needs. If FastAPI is a toolkit for APIs where you pick the database layer, admin and auth separately, Django offers a coherent set in which those parts are already wired together.

Database tables

What Django has
models and the ORM, migrations
Where in this lesson
Service, Inquiry, makemigrations

Editing data

What Django has
the django.contrib.admin admin
Where in this lesson
ServiceAdmin with a list, filters and search

Users and login

What Django has
django.contrib.auth
Where in this lesson
a superuser for the admin

Forms

What Django has
ModelForm, validators, CSRF protection
Where in this lesson
the inquiry form on a service page

Pages

What Django has
URL routes, views, templates
Where in this lesson
the service list and detail pages

Tests

What Django has
TestCase, the test client, a separate test database
Where in this lesson
seven tests via manage.py test

The project is a company’s service catalogue. A manager adds services in the admin and decides which to publish. A visitor sees the published services, opens one and requests a call back. Inquiries land in the same admin. A JSON endpoint lists the services for a frontend or mobile app.

Project and app

The environment is set up like any Python project: a virtual environment, then the package. The Django docs separate two ideas. A project is the configuration and set of apps for one website. An app is a module that does one thing: a blog, a catalogue, polls. A project can hold several apps, and one app can be used in several projects.

terminal
$ python -m venv .venv
$ source .venv/bin/activate          # Windows PowerShell: .venv\Scripts\Activate.ps1
$ python -m pip install "Django==6.1.2"
$ python -m django --version
6.1.2
$ django-admin startproject config services_site
$ cd services_site
$ python manage.py startapp catalog
Environment, installation and project creation. The version output is real

startproject config services_site creates a services_site folder with manage.py and a config settings package; startapp catalog creates the app. Naming the settings package config is a convention, not a requirement. The docs only warn against naming a project after built-in modules such as django or test.

  • manage.py — the project’s command utility
  • config/settings.py — settings: apps, database, templates, time zone
  • config/urls.py — root routes; asgi.py and wsgi.py — entry points for servers
  • catalog/models.py, admin.py, views.py, tests.py — the app’s models, admin, views and tests
  • catalog/migrations/ — migration files, for now an empty folder with __init__.py

Connect the app to the project by adding its configuration class from catalog/apps.py to INSTALLED_APPS. Set the time zone too: with USE_TZ on (the default), Django stores time in UTC and templates display it in the TIME_ZONE zone.

config/settings.py
INSTALLED_APPS = [
    'catalog.apps.CatalogConfig',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

# ...

TIME_ZONE = 'Asia/Almaty'
Fragment: what we change in the generated settings
  • manage.py is the entry point for commands: migrations, the development server, tests, creating users.
  • DATABASES points to a SQLite file, db.sqlite3, in the project root by default. SQLite ships with Python, so there’s nothing to install.
  • The generated SECRET_KEY starts with django-insecure-: it’s for development only. Production settings are covered at the end.

Models and migrations

A model is a subclass of django.db.models.Model; each attribute is a field in a database table. Django adds an id field itself. Since 6.0 it defaults to BigAutoField, a 64-bit auto-increment. The table name combines the app and model names: catalog_service.

catalog/models.py
from django.core.validators import RegexValidator
from django.db import models
from django.urls import reverse

phone_validator = RegexValidator(
    regex=r"^\+?\d{10,15}$",
    message="Enter 10–15 digits, optionally starting with +, e.g. +77011234567.",
)


class Service(models.Model):
    class Category(models.TextChoices):
        WEB = "web", "Websites"
        MOBILE = "mobile", "Mobile apps"
        AUTOMATION = "automation", "Automation"

    title = models.CharField(max_length=120)
    slug = models.SlugField(unique=True)
    category = models.CharField(max_length=20, choices=Category.choices)
    summary = models.CharField(max_length=255)
    description = models.TextField(blank=True)
    is_published = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["title"]

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse("catalog:service_detail", args=[self.slug])


class Inquiry(models.Model):
    service = models.ForeignKey(Service, on_delete=models.CASCADE, related_name="inquiries")
    name = models.CharField(max_length=80)
    phone = models.CharField(max_length=16, validators=[phone_validator])
    message = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-created_at"]
        verbose_name_plural = "inquiries"

    def __str__(self):
        return f"{self.name} — {self.service}"
Two models: a service and an inquiry about it
  • SlugField(unique=True) is a short Latin identifier for URLs (corporate-website), unique at the database level.
  • TextChoices defines the allowed categories: the database stores the value (web), forms and the admin show the label (Websites), and templates get the label from get_category_display().
  • ForeignKey(..., on_delete=models.CASCADE) links an inquiry to a service: delete the service and its inquiries go too. related_name="inquiries" gives reverse access: service.inquiries.all().
  • validators=[phone_validator] is a model-level check. Both ModelForm and the admin run it.
  • __str__ is the object’s string representation. The docs stress it’s used throughout the admin, so always define it.
  • get_absolute_url() returns the detail page address. Templates and redirect() use it.

A migration is a file in which Django records model changes. makemigrations creates that file, sqlmigrate shows the SQL it will run, and migrate applies everything not yet applied. Django tracks applied migrations in a special django_migrations table.

terminal
$ python manage.py makemigrations catalog
Migrations for 'catalog':
  catalog/migrations/0001_initial.py
    + Create model Service
    + Create model Inquiry

$ python manage.py sqlmigrate catalog 0001
BEGIN;
--
-- Create model Service
--
CREATE TABLE "catalog_service" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "title" varchar(120) NOT NULL, "slug" varchar(50) NOT NULL UNIQUE, "category" varchar(20) NOT NULL, "summary" varchar(255) NOT NULL, "description" text NOT NULL, "is_published" bool NOT NULL, "created_at" datetime NOT NULL);
--
-- Create model Inquiry
--
CREATE TABLE "catalog_inquiry" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "name" varchar(80) NOT NULL, "phone" varchar(16) NOT NULL, "message" text NOT NULL, "created_at" datetime NOT NULL, "service_id" bigint NOT NULL REFERENCES "catalog_service" ("id") DEFERRABLE INITIALLY DEFERRED);
CREATE INDEX "catalog_inquiry_service_id_41ee89bc" ON "catalog_inquiry" ("service_id");
COMMIT;

$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, catalog, contenttypes, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  …
  Applying catalog.0001_initial... OK
  Applying sessions.0001_initial... OK
Real output. The list of applied migrations is shortened at “…”: the first migrate also creates tables for built-in apps — users, sessions, the admin
  1. Change the model

    Add, rename or remove a field in models.py.

  2. makemigrations

    Django compares the models with the latest migration and creates a new file in catalog/migrations/.

  3. migrate

    Apply the changes to the database. The same command on the server brings the production database to the same schema.

The admin: registering models and tuning the list

The Django admin builds an editing interface straight from your models: forms, lists, search, a change history. Registering a model is enough. A ModelAdmin class controls how it’s shown.

catalog/admin.py
from django.contrib import admin

from .models import Inquiry, Service


@admin.register(Service)
class ServiceAdmin(admin.ModelAdmin):
    list_display = ["title", "category", "is_published", "created_at"]
    list_editable = ["is_published"]
    list_filter = ["category", "is_published"]
    search_fields = ["title", "summary"]
    prepopulated_fields = {"slug": ["title"]}


@admin.register(Inquiry)
class InquiryAdmin(admin.ModelAdmin):
    list_display = ["name", "phone", "service", "created_at"]
    list_filter = ["service"]
    search_fields = ["name", "phone"]
    readonly_fields = ["created_at"]
Registering models with the decorator and configuring the lists

list_display

What it does
columns in the object list. Without it, a single column with __str__

list_editable

What it does
fields editable right in the list. A field must also be in list_display

list_filter

What it does
sidebar filters: by category and publication status

search_fields

What it does
a search box above the list that searches the listed fields

prepopulated_fields

What it does
fills slug from title in the browser. After saving, the slug doesn’t change, so URLs don’t break

readonly_fields

What it does
read-only fields. They are excluded from the edit form
bash
python manage.py createsuperuser
python manage.py runserver
Create a user (the command asks for a username, email and password) and start the server

Open http://127.0.0.1:8000/admin/, log in and add two or three services. Leave one unpublished — you’ll need it for checks. Notice that the slug fills itself as you type the title, and publication status can be toggled right in the list.

Forms and validation

ModelForm builds a form from a model: field types, required flags, maximum lengths and validators come from Inquiry. The field list is set explicitly via fields. The docs strongly recommend this: fields = "__all__" or exclude can easily expose fields the user shouldn’t change, especially once new fields are added to the model.

catalog/forms.py
from django import forms

from .models import Inquiry


class InquiryForm(forms.ModelForm):
    class Meta:
        model = Inquiry
        fields = ["name", "phone", "message"]
The visitor fills in only name, phone and message. The server sets the service and the time
  1. GET — an empty form

    The view creates InquiryForm() and passes it to the template.

  2. POST — a bound form

    InquiryForm(request.POST) receives the data. is_valid() runs form validation and then model validation, including phone_validator.

  3. Error — the same page

    If the data is invalid, the page is rendered again: the form already holds the entered values and the error messages next to the fields.

  4. Success — save and redirect

    form.save(commit=False) creates the object without writing it, we set service and call save(). Then redirect: the docs advise always redirecting after a successful POST so the Back button doesn’t resubmit the form.

To protect against cross-site request forgery (CSRF), every POST form that targets your own URLs must include the {% csrf_token %} tag. Django’s middleware rejects a POST without a valid token with 403. We checked this on the running server: a request without the token got 403, while a request with the cookie and the token from the page got a 302 redirect and the inquiry appeared in the database.

terminal
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST -d "name=Aigerim&phone=%2B77011234567" http://127.0.0.1:8000/services/corporate-website/
403
A real check: a POST without a CSRF token is rejected

URLs, views and templates

A request travels like this: the root config/urls.py → include() hands the rest of the path to catalog/urls.py → a view function receives request and returns a response. app_name sets a namespace, so links are built from names like catalog:service_detail rather than hard-coded paths.

config/urls.py
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("services/", include("catalog.urls")),
]
Root routes: the admin and the catalogue
catalog/urls.py
from django.urls import path

from . import views

app_name = "catalog"

urlpatterns = [
    path("", views.service_list, name="service_list"),
    path("api/services/", views.service_list_json, name="service_list_json"),
    path("<slug:slug>/", views.service_detail, name="service_detail"),
]
<slug:slug> is a path converter: it captures part of the URL and passes it to the view as the slug argument
catalog/views.py
from django.contrib import messages
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render

from .forms import InquiryForm
from .models import Service


def service_list(request):
    services = Service.objects.filter(is_published=True)
    return render(request, "catalog/service_list.html", {"services": services})


def service_detail(request, slug):
    service = get_object_or_404(Service, slug=slug, is_published=True)
    if request.method == "POST":
        form = InquiryForm(request.POST)
        if form.is_valid():
            inquiry = form.save(commit=False)
            inquiry.service = service
            inquiry.save()
            messages.success(request, "Thank you! We will call you back.")
            return redirect(service)
    else:
        form = InquiryForm()
    return render(request, "catalog/service_detail.html", {"service": service, "form": form})


def service_list_json(request):
    services = Service.objects.filter(is_published=True).values("title", "slug", "category", "summary")
    return JsonResponse({"services": list(services)})
Three views: the list (HTML), the detail page with a form (HTML) and the list as JSON
  • render() loads a template, fills in the context and returns an HttpResponse.
  • get_object_or_404(Service, slug=slug, is_published=True) returns 404 if the service doesn’t exist or isn’t published. A draft can’t be opened even if you know its address.
  • redirect(service) calls the model’s get_absolute_url(). messages.success stores a message the template shows once after the redirect.
  • JsonResponse serialises a dict to JSON with Content-Type: application/json. By default it refuses a top-level list (safe=True), so the service list is wrapped in a services key.

Templates live in catalog/templates/catalog/. The inner folder named after the app is a namespace: Django searches every app’s templates and takes the first match, so without it identically named templates from different apps clash. The base template defines the page skeleton; the others extend it with {% extends %} and fill in its blocks.

catalog/templates/catalog/base.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>{% block title %}Services{% endblock %}</title>
</head>
<body>
  {% for message in messages %}
    <p role="status">{{ message }}</p>
  {% endfor %}
  {% block content %}{% endblock %}
</body>
</html>
The page skeleton and one-time messages
catalog/templates/catalog/service_list.html
{% extends "catalog/base.html" %}

{% block content %}
  <h1>Our services</h1>
  <ul>
    {% for service in services %}
      <li>
        <a href="{{ service.get_absolute_url }}">{{ service.title }}</a>
        — {{ service.get_category_display }}: {{ service.summary }}
      </li>
    {% empty %}
      <li>No services published yet.</li>
    {% endfor %}
  </ul>
{% endblock %}
{% empty %} renders when the list is empty
catalog/templates/catalog/service_detail.html
{% extends "catalog/base.html" %}

{% block title %}{{ service.title }}{% endblock %}

{% block content %}
  <h1>{{ service.title }}</h1>
  <p>{{ service.summary }}</p>
  {{ service.description|linebreaks }}

  <h2>Request a call</h2>
  <form method="post">
    {% csrf_token %}
    {{ form.as_div }}
    <button type="submit">Send</button>
  </form>
  <p><a href="{% url 'catalog:service_list' %}">All services</a></p>
{% endblock %}
The service page and the inquiry form with a CSRF token
terminal
$ python manage.py runserver
Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).
October 11, 2026 - 17:20:59
Django version 6.1.2, using settings 'config.settings'
Starting WSGI development server at http://127.0.0.1:8000/
Quit the server with CTRL-BREAK.

WARNING: This is a development server. Do not use it in a production setting. Use a production WSGI or ASGI server instead.
Real runserver output on Windows (on Linux and macOS the hint says CONTROL-C)
terminal
$ curl -s http://127.0.0.1:8000/services/api/services/ | python -m json.tool
{
    "services": [
        {
            "title": "CRM integration",
            "slug": "crm-integration",
            "category": "automation",
            "summary": "Leads from the site go straight to the CRM"
        },
        {
            "title": "Corporate website",
            "slug": "corporate-website",
            "category": "web",
            "summary": "A multi-page site with a CMS"
        }
    ]
}
The JSON endpoint: the draft isn’t included, and the order follows the model’s ordering
  • /services/ shows only published services with the category label
  • An unpublished service’s address returns 404
  • The form with the phone “call me” shows a field error and no inquiry is created
  • A valid inquiry redirects and shows “Thank you!”, and the inquiry appears in the admin
  • /services/api/services/ returns JSON with the same set of services

Tests: manage.py test

Django tests are subclasses of django.test.TestCase. Each test runs inside a transaction that is rolled back afterwards, so tests don’t affect each other. A separate blank database is created for the tests, leaving your real data untouched. setUpTestData creates shared data once per class, and self.client is a test client that sends requests to views without a running server.

catalog/tests.py
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse

from .models import Inquiry, Service


class CatalogTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.website = Service.objects.create(
            title="Corporate website",
            slug="corporate-website",
            category=Service.Category.WEB,
            summary="A multi-page site with a CMS",
            is_published=True,
        )
        cls.draft = Service.objects.create(
            title="Secret draft",
            slug="secret-draft",
            category=Service.Category.AUTOMATION,
            summary="Not ready yet",
        )

    def test_str_and_absolute_url(self):
        self.assertEqual(str(self.website), "Corporate website")
        self.assertEqual(self.website.get_absolute_url(), "/services/corporate-website/")

    def test_list_shows_only_published_services(self):
        response = self.client.get(reverse("catalog:service_list"))
        self.assertContains(response, "Corporate website")
        self.assertNotContains(response, "Secret draft")

    def test_draft_detail_returns_404(self):
        response = self.client.get(self.draft.get_absolute_url())
        self.assertEqual(response.status_code, 404)

    def test_valid_inquiry_is_saved_and_redirects(self):
        url = self.website.get_absolute_url()
        response = self.client.post(url, {"name": "Aigerim", "phone": "+77011234567"}, follow=True)
        self.assertRedirects(response, url)
        self.assertContains(response, "Thank you!")
        inquiry = Inquiry.objects.get()
        self.assertEqual(inquiry.service, self.website)

    def test_invalid_phone_shows_error_and_saves_nothing(self):
        response = self.client.post(self.website.get_absolute_url(), {"name": "Aigerim", "phone": "call me"})
        self.assertEqual(response.status_code, 200)
        self.assertFormError(response.context["form"], "phone", "Enter 10–15 digits, optionally starting with +, e.g. +77011234567.")
        self.assertEqual(Inquiry.objects.count(), 0)

    def test_json_endpoint_lists_published_services(self):
        response = self.client.get(reverse("catalog:service_list_json"))
        self.assertEqual(response["Content-Type"], "application/json")
        self.assertEqual(
            response.json(),
            {"services": [{"title": "Corporate website", "slug": "corporate-website", "category": "web", "summary": "A multi-page site with a CMS"}]},
        )

    def test_admin_changelist_opens_for_superuser(self):
        admin_user = get_user_model().objects.create_superuser("admin", "admin@example.com", "test-pass-123")
        self.client.force_login(admin_user)
        response = self.client.get(reverse("admin:catalog_service_changelist"))
        self.assertContains(response, "Corporate website")
Seven tests: the model, the list, 404, the form — success and error, JSON, the admin
terminal
$ python manage.py test
Found 7 test(s).
Creating test database for alias 'default'...

System check identified no issues (0 silenced).
.......
----------------------------------------------------------------------
Ran 7 tests in 0.484s

OK
Destroying test database for alias 'default'...
Real output
  • assertContains and assertNotContains check the response HTML. The list test catches the nastiest catalogue bug — a published draft.
  • follow=True follows the redirect, and assertRedirects checks the whole chain. Without it, the “Thank you!” message would be consumed when the redirect is checked and never reach the next request — we tripped over this while writing the lesson.
  • assertFormError(form, "phone", ...) checks the exact error text on a specific field.
  • force_login logs a user in without checking the password, so the admin test doesn’t depend on the login form.
  • The test client disables CSRF checks by default. That’s why POSTs in the tests pass without a token, and why we checked the protection itself on the running server. To test CSRF there’s Client(enforce_csrf_checks=True).

Common mistakes, production and exercises

no such table: catalog_service

Cause
migrations weren’t created or applied
Fix
makemigrations, then migrate

makemigrations says No changes detected

Cause
the app isn’t in INSTALLED_APPS
Fix
add catalog.apps.CatalogConfig

TemplateDoesNotExist: catalog/service_list.html

Cause
the template isn’t in catalog/templates/catalog/
Fix
check the inner folder named after the app

NoReverseMatch in a template

Cause
a route name without the namespace, or a typo
Fix
{% url 'catalog:service_list' %}, check app_name

403 when submitting a form

Cause
the form lacks {% csrf_token %}
Fix
add the tag inside <form method="post">

The model doesn’t appear in the admin

Cause
it isn’t registered in admin.py
Fix
@admin.register(Model)

Next: how the ORM turns into SQL and how to change the schema of a live database safely — in the ORM and migrations lesson. If you need only an API without pages or an admin, compare this lesson with FastAPI.

How this was verified3 · passed2 · partialchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.