Django — Python-фреймворк, в котором ORM, миграции базы, панель администратора, формы с защитой от CSRF, шаблоны и тестовый раннер идут в комплекте. Модель описывается классом Python, makemigrations и migrate превращают её в таблицу, а одна строка @admin.register даёт рабочий интерфейс для редактирования данных. В уроке собран каталог услуг с публичной страницей, формой заявки, JSON-вьюхой и семью тестами на Django 6.1.
После материала вы сможете
- создать проект и приложение Django, подключить приложение в
INSTALLED_APPSи объяснить разницу между ними - описать модели с полями,
choicesиForeignKey, создать и применить миграции командамиmakemigrationsиmigrate - зарегистрировать модели в админке и настроить
list_display,list_filter,search_fieldsиprepopulated_fields - отдать страницы через представления и шаблоны, принять форму с валидацией и CSRF и покрыть всё тестами
manage.py test
Что знать заранее
Проверено на
Python 3.12Django 6.1SQLite 3.49
Сверено с документацией: 11 октября 2026 г.
Содержание
«Батарейки в комплекте»: что даёт Django
Общая роль бэкенда — принимать запросы, работать с базой и отдавать ответы — разобрана в материале «Что такое бэкенд». Django решает эту задачу с готовыми компонентами под типичные нужды сайта. Если FastAPI — конструктор для API, где базу, админку и авторизацию подбирают отдельно, то Django предлагает целостный набор, где эти части уже связаны между собой.
Таблицы в базе
- Что есть в Django
- модели и ORM, миграции
- Где в уроке
Service,Inquiry,makemigrations
Редактирование данных
- Что есть в Django
- админка
django.contrib.admin - Где в уроке
ServiceAdminсо списком, фильтрами и поиском
Пользователи и вход
- Что есть в Django
django.contrib.auth- Где в уроке
- суперпользователь для админки
Формы
- Что есть в Django
ModelForm, валидаторы, CSRF-защита- Где в уроке
- форма заявки на странице услуги
Страницы
- Что есть в Django
- URL-маршруты, представления, шаблоны
- Где в уроке
- список и карточка услуги
Тесты
- Что есть в Django
TestCase, тестовый клиент, отдельная тестовая база- Где в уроке
- семь тестов через
manage.py test
Учебный проект — каталог услуг компании. Менеджер добавляет услуги в админке и решает, какие опубликовать. Посетитель видит список опубликованных услуг, открывает карточку и оставляет заявку на звонок. Заявки попадают в ту же админку. Для фронтенда или мобильного приложения есть JSON-эндпоинт со списком услуг.
Проект и приложение
Окружение создаётся так же, как в любом Python-проекте: виртуальное окружение, затем установка пакета. Документация Django разделяет два понятия. Проект — это настройки и набор приложений конкретного сайта. Приложение — модуль, который делает одну вещь: блог, каталог, опросы. В проекте может быть несколько приложений, а одно приложение можно подключить к разным проектам.
$ 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Команда startproject config services_site создаёт папку services_site с файлом manage.py и пакетом настроек config, а startapp catalog — приложение. Имя config для пакета настроек — договорённость, а не требование. Документация предупреждает только, что проект нельзя называть как встроенные модули, например django или test.
manage.py— утилита команд проектаconfig/settings.py— настройки: приложения, база, шаблоны, часовой поясconfig/urls.py— корневые маршруты;asgi.pyиwsgi.py— точки входа для серверовcatalog/models.py,admin.py,views.py,tests.py— модели, админка, представления и тесты приложенияcatalog/migrations/— файлы миграций, пока пустая папка с__init__.py
Приложение нужно подключить к проекту: добавить его класс конфигурации из catalog/apps.py в INSTALLED_APPS. Заодно задайте часовой пояс: при включённом USE_TZ (так по умолчанию) Django хранит время в UTC, а шаблоны показывают его в часовом поясе TIME_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.py— точка входа для команд: миграции, сервер разработки, тесты, создание пользователей.DATABASESпо умолчанию указывает на SQLite-файлdb.sqlite3в корне проекта. SQLite входит в Python, ничего ставить не нужно.SECRET_KEYв сгенерированном файле начинается сdjango-insecure-: это ключ только для разработки. О production-настройках — в конце урока.
Модели и миграции
Модель — класс-наследник django.db.models.Model, каждый атрибут которого — поле в таблице базы. Поле id Django добавляет сам. С версии 6.0 по умолчанию это BigAutoField, 64-битный автоинкремент. Имя таблицы складывается из имени приложения и модели: 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)— короткий латинский идентификатор для URL (corporate-website), уникальный на уровне базы.TextChoicesзадаёт допустимые категории: в базе хранится значение (web), в формах и админке показывается подпись (Websites), а в шаблоне её отдаёт методget_category_display().ForeignKey(..., on_delete=models.CASCADE)связывает заявку с услугой: удалите услугу — удалятся и её заявки.related_name="inquiries"даёт обратный доступ:service.inquiries.all().validators=[phone_validator]— проверка на уровне модели. Её выполнят иModelForm, и админка.__str__— строковое представление объекта. Документация подчёркивает, что его используют по всей админке, поэтому определять его стоит всегда.get_absolute_url()возвращает адрес карточки. Его используют шаблоны иredirect().
Миграция — файл, в котором Django записывает изменения моделей. makemigrations создаёт такой файл, sqlmigrate показывает SQL, который он выполнит, а migrate применяет к базе всё, что ещё не применено. Какие миграции уже выполнены, Django хранит в служебной таблице django_migrations.
$ 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 создаёт и таблицы встроенных приложений — пользователей, сессий, админкиИзменить модель
Добавить, переименовать или удалить поле в
models.py.makemigrationsDjango сравнивает модели с последней миграцией и создаёт новый файл в
catalog/migrations/.migrateПрименить изменения к базе. Та же команда на сервере приведёт production-базу к той же схеме.
Админка: регистрация и настройка списка
Админка Django строит интерфейс редактирования прямо по моделям: формы, списки, поиск, журнал изменений. Достаточно зарегистрировать модель. Класс ModelAdmin настраивает, как её показывать.
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
- Что делает
- колонки в списке объектов. Без неё — одна колонка со
__str__
list_editable
- Что делает
- поля, которые правятся прямо в списке. Поле должно быть и в
list_display
list_filter
- Что делает
- фильтры в боковой панели: по категории и статусу публикации
search_fields
- Что делает
- поле поиска над списком: ищет по перечисленным полям
prepopulated_fields
- Что делает
- заполняет
slugизtitleпрямо в браузере. После сохранения slug не меняется — чтобы не ломать URL
readonly_fields
- Что делает
- поля только для чтения. Они исключаются из формы редактирования
python manage.py createsuperuser
python manage.py runserverОткройте http://127.0.0.1:8000/admin/, войдите и добавьте две-три услуги. Одну оставьте неопубликованной — она понадобится для проверки. Обратите внимание: поле slug заполняется само, пока вы набираете название, а статус публикации можно переключать прямо в списке.
Формы и валидация
ModelForm строит форму по модели: типы полей, обязательность, максимальная длина и валидаторы берутся из Inquiry. Список полей задаётся явно через fields. Документация настойчиво рекомендует именно так: fields = "__all__" или exclude легко открывают пользователю поля, которые он не должен менять, особенно когда в модель потом добавят новые.
from django import forms
from .models import Inquiry
class InquiryForm(forms.ModelForm):
class Meta:
model = Inquiry
fields = ["name", "phone", "message"]GET — пустая форма
Представление создаёт
InquiryForm()и передаёт его в шаблон.POST — связанная форма
InquiryForm(request.POST)получает данные.is_valid()запускает проверку формы, а затем проверку модели — включаяphone_validator.Ошибка — та же страница
Если данные неверны, страница рендерится снова: форма уже содержит введённые значения и тексты ошибок у полей.
Успех — сохранение и редирект
form.save(commit=False)создаёт объект без записи в базу, мы проставляемserviceи вызываемsave(). Затем — редирект: документация советует всегда отвечать редиректом после успешного POST, чтобы кнопка «Назад» не отправила форму повторно.
Для защиты от подделки межсайтовых запросов (CSRF) каждая POST-форма на свои же адреса должна содержать тег {% csrf_token %}. Middleware Django отклоняет POST без корректного токена с кодом 403. Мы проверили это на запущенном сервере: запрос без токена получил 403, а запрос с cookie и токеном со страницы — редирект 302, и заявка появилась в базе.
$ 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/
403URL, представления и шаблоны
Запрос проходит путь: корневой config/urls.py → include() передаёт остаток адреса в catalog/urls.py → функция-представление (view) получает request и возвращает ответ. app_name задаёт пространство имён: ссылки строятся по именам вида catalog:service_detail, а не по захардкоженным путям.
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> — конвертер пути: захватывает часть URL и передаёт её в представление аргументом slugfrom 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()загружает шаблон, подставляет контекст и возвращаетHttpResponse.get_object_or_404(Service, slug=slug, is_published=True)отдаёт 404, если услуги нет или она не опубликована. Черновик нельзя открыть, даже зная адрес.redirect(service)вызываетget_absolute_url()модели.messages.successкладёт сообщение, которое шаблон покажет один раз после редиректа.JsonResponseсериализует словарь в JSON сContent-Type: application/json. Список верхнего уровня он по умолчанию не принимает (safe=True), поэтому список услуг обёрнут в ключservices.
Шаблоны лежат в catalog/templates/catalog/. Вложенная папка с именем приложения — пространство имён: Django ищет шаблоны во всех приложениях и берёт первый подходящий, поэтому без неё одноимённые шаблоны разных приложений конфликтуют. Базовый шаблон задаёт каркас страницы, остальные наследуют его через {% extends %} и заполняют блоки.
<!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 %} срабатывает, если список пуст{% 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 на Windows (на Linux и macOS подсказка — 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/показывает только опубликованные услуги с подписью категории- Адрес неопубликованной услуги отвечает 404
- Форма с телефоном «call me» возвращает ошибку у поля, заявка не создаётся
- После корректной заявки — редирект и сообщение «Thank you!», заявка видна в админке
/services/api/services/отдаёт JSON с тем же набором услуг
Тесты: manage.py test
Тесты Django — классы-наследники django.test.TestCase. Каждый тест выполняется внутри транзакции, которая потом откатывается, поэтому тесты не влияют друг на друга. Для тестов создаётся отдельная пустая база: рабочие данные не затрагиваются. setUpTestData создаёт общие данные один раз на класс, а self.client — тестовый клиент, который отправляет запросы к представлениям без запуска сервера.
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'...assertContainsиassertNotContainsпроверяют HTML ответа. Тест списка ловит самую неприятную ошибку каталога — опубликованный черновик.follow=Trueпроходит редирект, иassertRedirectsпроверяет всю цепочку. Без этого сообщение «Thank you!» было бы прочитано при проверке редиректа и не дошло бы до следующего запроса — на этом мы споткнулись при написании урока.assertFormError(form, "phone", ...)проверяет конкретный текст ошибки у конкретного поля.force_loginвходит пользователем без проверки пароля — так тест админки не зависит от формы входа.- Тестовый клиент по умолчанию отключает CSRF-проверки. Поэтому POST в тестах проходит без токена, а саму защиту мы проверили на запущенном сервере. Для теста CSRF есть
Client(enforce_csrf_checks=True).
Частые ошибки, production и упражнения
no such table: catalog_service
- Причина
- миграции не созданы или не применены
- Исправление
makemigrations, затемmigrate
makemigrations пишет No changes detected
- Причина
- приложение не добавлено в
INSTALLED_APPS - Исправление
- добавить
catalog.apps.CatalogConfig
TemplateDoesNotExist: catalog/service_list.html
- Причина
- шаблон не в
catalog/templates/catalog/ - Исправление
- проверить вложенную папку с именем приложения
NoReverseMatch в шаблоне
- Причина
- имя маршрута без пространства имён или опечатка
- Исправление
{% url 'catalog:service_list' %}, проверитьapp_name
403 при отправке формы
- Причина
- в форме нет
{% csrf_token %} - Исправление
- добавить тег внутрь
<form method="post">
Модель в админке не появилась
- Причина
- нет регистрации в
admin.py - Исправление
@admin.register(Model)
Дальше: как ORM превращается в SQL и как безопасно менять схему работающей базы — в уроке об ORM и миграциях. Если нужен только API без страниц и админки, сравните этот урок с FastAPI.
Как проверено3 · пройдено2 · частичносверено 11 октября 2026 г.
- запущено, работает
config/settings.py (фрагмент), catalog/models.py, admin.py, forms.py, views.py, urls.py, config/urls.py, 3 шаблона, tests.py
python -m venv в scratchpad, Django 6.1.2, Python 3.12.10, SQLite 3.49.1 (Windows 11): manage.py check — no issues; makemigrations/sqlmigrate/migrate — вывод в статье реальный (миграции сокращены «…», разделители путей приведены к /); manage.py test — Ran 7 tests, OK. Код вставлен в статью скриптом из файлов проекта
- запущено, работает
runserver + curl
/services/ 200 (только опубликованные), /services/secret-draft/ 404, /admin/ 302 на логин, JSON-эндпоинт 200 application/json; POST без CSRF-токена 403, с cookie и токеном 302 + сообщение «Thank you!», заявка в базе. createsuperuser проверен в режиме --noinput (DJANGO_SUPERUSER_PASSWORD), интерактивный режим не запускался
- запущено, работает
упражнения: Service.objects.all(); поле position + миграция 0002; JSON одной услуги
ex1 — FAILED (failures=1) именно test_list_shows_only_published_services, вывод реальный (сокращён HTML); ex2 — makemigrations создал 0002_alter_service_options_service_position, migrate OK, 8 tests OK; ex3 — 8 tests OK
- частично
совместимость с Django 5.2 LTS
тот же код приложения на Django 5.2.18: manage.py test — 7 tests OK; с настройками, сгенерированными 6.1, makemigrations --check показывает изменение типа id (AutoField по умолчанию в 5.2) и 2 предупреждения W042 — проект, созданный startproject из 5.2, отдельно не собирался
- частично
команды установки (shellsession)
выполнены на Windows эквивалентом (.venv/Scripts/python …); активация source .venv/bin/activate на Linux/macOS не запускалась — синтаксис сверен с документацией venv
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.