Личный кабинет в Django: приёмы, которые экономят часы
Личный кабинет — не одна вьюха, а десяток решений про профиль, права, редактирование данных и защиту чужих объектов. Разбираем рабочие подходы и их цену.

Личный кабинет пользователя в Django выглядит просто ровно до того момента, пока в нём не появляется вторая страница. Дальше начинаются вопросы: где хранить телефон и аватар, если User их не предусматривает; как показать пользователю только его заказы, а не чужие; что делать, когда одну и ту же форму редактирования открывают и владелец, и администратор. Разберём приёмы, проверенные на проектах, где кабинет — не витрина, а рабочий инструмент с десятками полей и правами доступа. Ориентируемся на Django 5.x, но большинство подходов работает и в 4.2 LTS.
Куда класть данные пользователя
Стандартная модель django.contrib.auth.models.User знает про имя, email и пароль — и ничего про телефон, город или согласие на рассылку. Три способа расширить её, у каждого своя цена.
| Подход | Что выигрываете | Чем платите |
|---|---|---|
Кастомная модель через AbstractUser | Все поля в одной таблице, один запрос | Задать нужно до первой миграции, потом менять больно |
Отдельная модель Profile с OneToOneField | Можно внедрить в существующий проект в любой момент | Лишний JOIN или отдельный запрос на профиль |
Расширение AbstractBaseUser | Полный контроль, вход по телефону вместо username | Придётся писать менеджер и переопределять почти всё |
Если проект только начинается — берите AbstractUser и указывайте AUTH_USER_MODEL сразу. Это правило Django повторяет в документации не зря: сменить пользовательскую модель после применения миграций — операция на пару дней с ручным переносом внешних ключей.
# settings.py
AUTH_USER_MODEL = "accounts.User"
# accounts/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
phone = models.CharField("Телефон", max_length=20, blank=True)
city = models.CharField("Город", max_length=100, blank=True)
# согласие на рассылку — отдельным полем, не в JSON
marketing_opt_in = models.BooleanField(default=False)
Для существующего проекта, где User уже наплодил сотни строк и внешних ключей, OneToOneField — единственный безболезненный путь. Профиль создавайте сигналом на post_save пользователя, но не забудьте про пользователей, созданных до внедрения профиля — им профиль придётся создать миграцией данных.
Показать пользователю только его
Самая частая дыра в кабинетах — вьюха вида Order.objects.get(pk=pk), где pk берётся из URL. Пользователь меняет цифру в адресе и видит чужой заказ. Django не защищает от этого автоматически: фильтрацию по владельцу пишете вы.
Правильный приём — фильтровать queryset по request.user, а не проверять владельца после выборки:
from django.views.generic import DetailView
from .models import Order
class OrderDetailView(DetailView):
model = Order
template_name = "account/order.html"
def get_queryset(self):
# чужой заказ просто не попадёт в выборку — будет 404, а не 403
return Order.objects.filter(user=self.request.user)
Разница принципиальная. Если сначала достать объект, а потом сравнить obj.user == request.user, вы уже загрузили чужую строку в память и рискуете утечкой через сообщение об ошибке. Фильтрация на уровне queryset отдаёт честный 404: объекта для этого пользователя не существует.
Владельца объекта проверяйте фильтром queryset, а не сравнением после выборки. 404 на несуществующем-для-вас объекте безопаснее, чем 403 на чужом: вы не подтверждаете, что объект вообще есть.
Одна форма, два сценария редактирования
Профиль редактирует и сам пользователь, и администратор из служебного раздела. Соблазн — сделать одну UpdateView с флагом if request.user.is_staff. Это работает ровно до момента, когда админу нужно менять поля, недоступные пользователю (например, статус верификации), а пользователю — те, что админу не важны.
Разделяйте формы, а не вьюхи через if. Две ModelForm с разным набором полей на одной модели читаются проще, чем одна форма с ветвлением внутри:
from django import forms
from .models import User
class UserSelfEditForm(forms.ModelForm):
class Meta:
model = User
fields = ["first_name", "last_name", "phone", "city"]
class UserAdminEditForm(forms.ModelForm):
class Meta:
model = User
# админу доступно больше — статус, роль
fields = ["first_name", "last_name", "phone",
"city", "is_active", "is_verified"]
Вьюха пользователя всегда работает с request.user и UserSelfEditForm. Даже если кто-то подставит чужой pk в URL, редактировать он будет только себя — объект берётся из сессии, а не из адреса.
Смена пароля и email без граблей
Пароль не редактируйте как обычное поле формы: ModelForm запишет введённую строку в открытом виде мимо хеширования. Django для этого держит отдельный набор вьюх в django.contrib.auth.views — PasswordChangeView и PasswordChangeDoneView. Подключайте их, а не изобретайте свою обработку.
Смена email — отдельная боль. Если email используется для входа, менять его без подтверждения на новый адрес опасно: опечатка отрежет пользователя от аккаунта. Рабочая схема:
- Пользователь вводит новый email, вы сохраняете его в отдельное поле
pending_email, а не перезаписываете основной. - Отправляете письмо с подписанной ссылкой. Для генерации токена берите
django.core.signing.TimestampSigner— он даёт срок годности из коробки. - По переходу проверяете подпись и срок, только тогда переносите
pending_emailв основное поле.
Не храните в токене сырой email — храните id пользователя и подписывайте. Проверку срока делайте параметром max_age при вызове unsign.
Аватары и файлы: где всё падает в проде
Загрузка аватара через ImageField локально работает всегда. В проде начинаются сюрпризы:
- Файлы не отдаются. При
DEBUG = FalseDjango не раздаётMEDIA_URL— это задача веб-сервера или объектного хранилища. Настройте отдачу через Nginx или подключите S3-совместимое хранилище. - Пользователь грузит 40-мегапиксельный JPEG. Без ограничения размера и ресайза вы храните гигабайты и тормозите отдачу. Ограничивайте размер в форме (
clean_avatar) и делайте превью. - Старый файл не удаляется. При замене аватара Django оставляет прежний файл на диске. Чистку вешайте на сигнал или фоновую задачу — иначе диск заполнится молча.
N+1 на странице заказов
Кабинет со списком заказов, где у каждого выводится статус, товары и адрес доставки, — классический источник N+1 запросов. Шаблон дёргает связанные объекты в цикле, и на странице из 30 заказов вы получаете сотни запросов к базе.
Лечится двумя методами queryset, и путать их нельзя:
select_related— дляForeignKeyиOneToOne. Делает JOIN, тянет связанное одним запросом. Подходит для «заказ → пользователь», «заказ → адрес».prefetch_related— для обратных связей иManyToMany. Делает отдельный запрос и склеивает в Python. Подходит для «заказ → его позиции».
Проверяйте не на глаз, а через django-debug-toolbar или connection.queries в тесте: число запросов на странице списка не должно расти с числом строк.
Что настроить дальше
Когда базовый кабинет работает, займитесь тем, что видно только под нагрузкой и в логах:
- Ограничьте частоту смены пароля и попыток входа —
django-axesили свой троттлинг, иначе форма входа превращается в стенд для перебора. - Замерьте число SQL-запросов на каждой странице кабинета через debug-toolbar и зафиксируйте потолок в тестах.
- Логируйте изменения чувствительных полей (email, телефон) — при споре с пользователем это единственный источник правды.
- Проверьте, что все вьюхи кабинета закрыты
LoginRequiredMixin— забытый миксин на одной вьюхе открывает её всему интернету.
Валидацию email-подтверждений, двухфакторную аутентификацию и SSO здесь не рассматривали — это отдельные темы, каждая тянет на свой разбор.
Инфобез и 152-ФЗ: Аудит политик конфиденциальности, правила сбора cookie.
Спросить за 20 ₽Источники: Django docs: Customizing authentication, Django docs: QuerySet API — select_related и prefetch_related
Частые вопросы
Можно ли сменить модель пользователя на кастомную, если проект уже работает?
У меня Django 4.2, а не 5.x — приёмы подойдут?
Почему лучше отдавать 404 на чужом объекте, а не 403?
Аватары загружаются локально, но в проде не показываются — почему?
Как понять, что на странице заказов проблема N+1, а не просто медленная база?
Форму смены пароля можно сделать обычной ModelForm?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.