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.
After this you will be able to
- Create a Django project and app, register the app in
INSTALLED_APPSand explain the difference between the two - Describe models with fields,
choicesandForeignKey, and create and apply migrations withmakemigrationsandmigrate - Register models in the admin and configure
list_display,list_filter,search_fieldsandprepopulated_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
“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.adminadmin - Where in this lesson
ServiceAdminwith 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.
$ 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 catalogstartproject 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 utilityconfig/settings.py— settings: apps, database, templates, time zoneconfig/urls.py— root routes;asgi.pyandwsgi.py— entry points for serverscatalog/models.py,admin.py,views.py,tests.py— the app’s models, admin, views and testscatalog/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.
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'manage.pyis the entry point for commands: migrations, the development server, tests, creating users.DATABASESpoints 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_KEYstarts withdjango-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.
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}"SlugField(unique=True)is a short Latin identifier for URLs (corporate-website), unique at the database level.TextChoicesdefines the allowed categories: the database stores the value (web), forms and the admin show the label (Websites), and templates get the label fromget_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. BothModelFormand 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 andredirect()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.
$ 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... OKmigrate also creates tables for built-in apps — users, sessions, the adminChange the model
Add, rename or remove a field in
models.py.makemigrationsDjango compares the models with the latest migration and creates a new file in
catalog/migrations/.migrateApply 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.
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"]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
slugfromtitlein 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
python manage.py createsuperuser
python manage.py runserverOpen 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.
from django import forms
from .models import Inquiry
class InquiryForm(forms.ModelForm):
class Meta:
model = Inquiry
fields = ["name", "phone", "message"]GET — an empty form
The view creates
InquiryForm()and passes it to the template.POST — a bound form
InquiryForm(request.POST)receives the data.is_valid()runs form validation and then model validation, includingphone_validator.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.
Success — save and redirect
form.save(commit=False)creates the object without writing it, we setserviceand callsave(). 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.
$ 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/
403URLs, 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.
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("services/", include("catalog.urls")),
]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 argumentfrom 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)})render()loads a template, fills in the context and returns anHttpResponse.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’sget_absolute_url().messages.successstores a message the template shows once after the redirect.JsonResponseserialises a dict to JSON withContent-Type: application/json. By default it refuses a top-level list (safe=True), so the service list is wrapped in aserviceskey.
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.
<!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>{% 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{% 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 %}$ 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.runserver output on Windows (on Linux and macOS the hint says CONTROL-C)$ 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"
}
]
}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.
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")$ 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'...assertContainsandassertNotContainscheck the response HTML. The list test catches the nastiest catalogue bug — a published draft.follow=Truefollows the redirect, andassertRedirectschecks 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_loginlogs 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, thenmigrate
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' %}, checkapp_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.