Окей, Джанго, у меня к тебе несколько вопросов
Недавно я проходил очередное интервью, и меня спросили, пишу ли я на flask, на что я ответил, что я себя люблю, и поэтому пишу на django. Меня не взяли, потому что, кхм, у них, оказывается, много чего было на фласке, и вышло неловко. Да-да, я знаю, фласк крут, потому что он простой, всё что надо ставишь сам, а чего не надо там и так нет, но как по мне, всё равно потом получается django.
И тут, наверно, покажется, что я я свидетель Джанго, хожу по домам, стучу в двери и рассказываю, как круто на нём кодить, но вообще-то нет — Джанго тоже не без проблем… Вот об этом я и хочу поговорить.
Async
Давайте поставим вопрос вот так: что человек представляет, когда ему говорят «в Джанго 3.0 добавили поддержку асинхронности»? Ну как, это значит, что все функции, обрабатывающие запросы от клиентов, асинхронные, то есть Петя запросил страничку, воркер принял запрос, пнул базу данных, и пока она достаёт свои индексы, поток выполнения прыгает в другую функцию, которая обрабатывает уже запрос от Васи, пинает базу данных, и пока она достаёт свои индексы, прыгает обратно к Пете… Ну вы поняли.
Так-то оно так, но есть одна маленькая и неприметная деталь: в реальном мире нихрена не работает. Почему? Ну, потому что реальные приложения (внезапно) используют базы данных, а django ORM всё ещё синхронная:
We«re still working on async support for the ORM and other parts of Django. You can expect to see this in future releases. For now, you can use the
sync_to_async()
adapter to interact with the sync parts of Django.
Но если этого вам мало, то вот ещё: не все middleware поддерживают async. Какие именно, конечно же, Джанго не говорит и позволяет вам узнать это самостоятельно в виде домашнего упражнения:
Middleware can be built to support both sync and async contexts. Some of Django«s middleware is built like this, but not all. To see what middleware Django has to adapt, you can turn on debug logging for the
django.request
logger and look for log messages about «Synchronous middleware… adapted».
Ну я и попробовал, мне ничего не вывелось. Вот ещё один чел — у него тоже не получилось. Значит, все middleware по умолчанию асинхронные? Ну хорошо, наверно…
Но и это ещё не всё! Я даже боюсь представить, сколько батареек всё ещё остались синхронными. Нельзя просто pip install django-whatever
и ожидать, что оно заработает с async.
Получается, что async django — это ходьба по минному полю: пока вы находитесь в «async scope», всё хорошо, но как только вам попадается синхронный код, неважно где — в middleware, или вы делаете запрос к БД, или какая-то батарейка юзает requests, а не httpx — то ваше приложение внезапно становится синхронным, или оно как-то автоматически конвертируется в асинхронное с потерей производительности.
Я обычно оптимист — мне всё кажется, что хренак-хренак, и проект уже готов. Но тут даже я пасую перед количеством часов, нужным, чтобы перевести экосистему Джанго на async.
В общем, вы можете почитать сами, как Джанго пытается усидеть на двух стульях — sync и async -, но как по мне, выглядит это странно, а некоторые считают, что Джанго так и должен оставаться синхронным, отдавая async в руки более современных фреймворков.
Батарейки
Джанго знаменит своими батарейками. Возьмите свою самую безумную фантазию, и для неё найдётся пакет для Джанго. Хоспаде, там есть всё, даже whitenoise для тех, кому лень настраивать nginx.
В самом же Django есть т.н. django.contrib
папка, в которой куча всего, что не нужно, и нету того, что нужно. Например:
Sites framework
Позволяет хостить несколько сайтов в одном приложении и разделять данные между ними.
И ладно бы если вы реально его используете — ну там у вас много сайтов и вы их разделяете по SITE_ID
в settings.py
(что мне кажется довольно странным, я бы разделял по какому-нибудь строковому идентификатору, типа kuku.com
и haha.net
, потому что «database ID», на который ссылается SITE_ID
, может быть разным в разных окружениях). Но даже если вы его не используете, то джанго сказал, что вы его используете, потому что некоторые приложения требуют его подключения. Ура, спасибо.
Кстати, хохма из документации:
You can use the sites framework in your Django views to do particular things based on the site in which the view is being called. For example:
from django.conf import settings
def my_view(request):
if settings.SITE_ID == 3:
# Do something.
pass
else:
# Do something else.
pass
Я уже было открыл рот, что тут magic number, но Джанго меня опередил:
It«s fragile to hard-code the site IDs like that, in case they change. The cleaner way of accomplishing the same thing is to check the current site«s domain:
from django.contrib.sites.shortcuts import get_current_site
def my_view(request):
current_site = get_current_site(request)
if current_site.domain == 'foo.com':
# Do something
pass
else:
# Do something else.
pass
А, нет, всё в порядке, тут просто «magic number» заменили на «hardcoded value» (-‸ლ)
Env vars
Многие приложения настраиваются при помощи переменных окружения — это де-факто стандарт. Поэтому для этого в django нет ничего встроенного, и он вас учит их не использовать — читайте ниже.
Tests
Для тестирования есть pytest — удобно, быстро и гибко. Но мало кто знает, что у pytest есть фатальный недостаток. Поэтому в джанго есть своя система тестирования, которая, конечно же, хуже.
Отладка
Отчёты
Джанго пишет очень подробные отчёты об ошибках. Но не дай бог у вас не 500 internal server error, а просто что-то тормозит. И хотя вот совсем рядом лежит django-debug-toolbar, которое показывает вообще всё для вашего приложения (в том числе запросы к БД с таймингами!), в Django оно не входит, потому что… ну не знаю, потому что это не так важно, как sites framework.
Но вот и 500ая ошибка, например:
Джанго как бы говорит: «эй, чел, ошибка в валидаторе какого-то поля, ты сравниваешь строку и число, а дальше…»
Error reports
Без сарказма, идея отправлять ошибки на email администратора — гениально! Это реально удобно, особенно когда какой-нибудь сервис для отлова ошибок ещё не прикручен. Проблема только одна: ошибки не группируются. Не дай бог вам сделать где-то ошибку и задеплоить её — на каждый вызов ошибки вам прилетит письмо на почту, и скоро ваш ящик превратится в помойку.
Чтобы вышеупомянутый пункт реально вас порадовал, разработчики Джанго сделали так, что invalid host header — то есть несоответствие header Host: xxx.com
какому-то домену из ALLOWED_HOSTS
— вызвает ошибку 500. Логика, наверно, такая: раз какой-то хрен с горы указал неправильный заголовок, то ваш сайт должен упасть, а админу должно прийти уведомление. Неплохо, Джанго!
Вот моя почта:
Syndication framework (RSS / atom feeds)
Не аналитика посетителей. Не fingerprinting. Не иерархические / строго типизированные настройки, нет. Вам нужен RSS!
Sessions
Можно процитировать?
В Джанго встроен прекрасный механизм сессий, однако данные хранятся в виде base64. Из-за этого очень сложно получить данные обо всех активных сессиях пользователя. — https://github.com/jazzband/django-user-sessions
Ну чо, действительно прекрасный механизм.
А ещё в сессиях есть встроенный детектор изменений, но вот работает только в тривиальных случаях:
# Session is modified.
request.session['foo'] = 'bar'
# Session is modified.
del request.session['foo']
# Session is modified.
request.session['foo'] = {}
# Gotcha: Session is NOT modified, because this alters
# request.session['foo'] instead of request.session.
request.session['foo']['bar'] = 'baz'
Я понимаю, почему это так, но как по мне, то лучше либо сразу всё, либо никак, без всяких gotcha. Сельский парень требует простого и явного ¯_(ツ)_/¯
Jazzband
Если вы улетели в другую страну и вдруг по прилёту обнаружили, что забыли взять свои чемоданы — то можно попросить кого-нибудь дослать их почтой. Вот jazzband — это как раз такой чемодан с забытыми вещами.
И вещи реально полезные, как если бы вы не взяли в путешествие трусы или деньги:
django-constance
django-redis
django-auditlog
django-robots
django-model-utils
django-pipeline
sorl-thumbnail
…
Шаблоны (templates)
Rocket science
Я верю, что бог устроил великий потоп, потому что знал, что родятся разрабы django и придумают Django templates, и хотел это предотвратить.
Не вышло.
Когда я только пришёл с php, где было нормой открывать соединение к БД прямо в шаблоне и в шаблоне же что-то считать и ставить куки, я был поражён архитектурой MVT в Django — то есть можно разделить модели, обработку запроса и рендеринг! И всё бы тут хорошо, но вот шаблоны… Если в php была одна крайность — я мог сделать всё в шаблоне — то тут я мог сделать чуть больше, чем ничего: отобразить переменную, атрибут объекта или вызвать его метод (но только без аргументов!), или преобразовать что-то во что-то при помощи фильтра (но только 1 аргумент), или запилить templatetag, если нужно >1 аргументов.
Слишком много вопросов:
Почему template filter принимает только один аргумент?
Почему я должен регистрировать фильтр? Оно может само?
Почему если два аргумента, то уже template tag?
Почему включение одного шаблона в другой скрывается в inclusion tag, вместо того, чтобы делать это явно в шаблоне? Я ведь из шаблона вызываю код. который рендерит ещё шаблон.
Зачем takes_context, если можно передать нужные переменные явно?
Зачем сплиттить на django.template.Node, а потом джойнить обратно?
…и так до бесконечности.
Есть template filters, которые просто функции, и есть template tags, которые просто функции — хм, что-то здесь не так… Причём их не хватало, и чтобы добавить какой-то функционал, мне нужно было писать simpletag… или include_tag… или можно фильтром обойтись… Короче, я никогда не мог запомнить это и постоянно лазил в документацию по шаблонам.
Напомню проблему, которую мы хотели решить: нужно в шаблоне (html файле) просто вызвать чёртову функцию с аргументами и напечатать результат! Это же, мать вашу, не рокет саенс!
Дело изменилось, когда я открыл для себя jinja2, в котором можно просто писать python код. Нужна какая-то функция? Добавить её в глобальный или локальный контекст шаблона и используй. Нужно вставить кусок html в шаблон? Пожалуйста, юзайте include с явным контекстом.
Конечно, тут появляется возможность делать в шаблоне слишком много, но, как по мне, это лучше, чем когда создатели фреймворка насильно ограничивают разработчиков.
И вы, наверно, подумаете: раз jinja шаблоны такие выразительные, значит, за это приходится чем-то платить… Кхм, они ещё и быстрее джанговских.
Казалось бы, проблема решена — используй что тебе нравится -, но не тут-то было, ведь по умолчанию там стоят стандартные ублюдские шаблоны Джанго, то есть в каждом новом проекте я буду их встречать, каждый новый разработчик будет их изучать, а в своих проектах у меня всё равно будет Джанго админка, которая целиком на стандартных шаблонах.
Спасибо, Джанго!
Template dirs
Где искать шаблоны? Ну, смотря какие настройки. Могут быть в одной папке. Могут в каждом приложении быть в специальной папке, но на самом деле это не работает как неймспейсы, поэтому в итоге все эти папки всё равно как бы сливаются в одну большую мега-папку. Из этого следует, что если у вас есть app1/templates/home.html
и app2/templates/home.html
, то готовьтесь к бою гладиаторов: выживет сильнейший, а проигравший никогда не будет использован. Отсюда распространённый хак — кидать шаблоны в подпапки типа app1/templates/app1/home.html
. Выглядит так себе, но работает.
Добавьте к этому то, что сторонние приложения тоже могут иметь шаблоны, и что там в итоге находится в «мега-папке» после слияния всех шаблонов под капотом — хрен его знает.
Debugging
Вот что пишет Джанго про фильтры:
Since the template language doesn«t provide exception handling, any exception raised from a template filter will be exposed as a server error. Thus, filter functions should avoid raising exceptions if there is a reasonable fallback value to return. In case of input that represents a clear bug in a template, raising an exception may still be better than silent failure which hides the bug.
Типа, если на вход фильтру передаётся что-то совсем неожиданное, то лучше свалиться в Exception. Окей, Джанго, ты же так и делаешь, да?
async def view(request):
return TemplateResponse(request, 'template.html',
{'items': [1, 2, 3, 4, 5]})
# template.html
{{ non_existent }} # ничего не выведет (по умолчанию)
{{ items|add:"2" }} # ничего не выведет
{{ items|capfirst }} # выведет [1, 2, 3, 4, 5] (как вы понимаете, единица тут заглавная, а остальные цифры строчные)
{{ items|date:"D d M Y" }} # ничего не выведет
{{ items|dictsort:"name" }} # ничего не выведет
Вообще здорово, что по умолчанию неопределённые переменные заменяются на пустую строку. Но можно поставить настройку string_if_invalid = 'ERROR'
, и вместо пустой строки будет выведено ERROR
. Дебаггинг уровня «бог»!
В остальных случаях джанго продолжает славную традицию PHP: пробует сконвертировать данные хоть во что-нибудь и хоть что-то вывести и не упасть. Поэтому list можно передавать в |date
, |dictsort
и куда угодно. Забавно, что capfirst
сработало. А почему бы не сделать, чтобы необъявленные переменные приводили к ошибкам? Ну, как jinja2.StrictUndefined
, например.
Окей, допустим, шаблоны не падают и позволяют писать что угодно. Как это дебажить? Из коробки — никак. Вернее, есть {% debug %}
, который выведет в шаблон всё, что знает о текущем контексте и настройках, а дальше вы уж сами. К счастью, люди придумали всякое разное, тот же pycharm позволяет ставить брейкпоинты прямо в шаблонах, но вообще-то странно, что это не идёт в комплекте с джанго.
Вот у меня упал шаблон джанго-админки, потому что в какой-то модели я вместо str
вернул uuid4
тип. Джанго любезно подсвечивает: чувак, у тебя ошибка в оригинале inline-формы.
`
Что? Где именно ошибка? Ну вот тут, смотри, я что-то пытаюсь перевести в строку и падаю:
Ниже одно из моих любимых: «Чувак, ты не можешь сравнить Decimal
и decimal.Decimal
. Где происходит это сравнение, в каких полях модели? Ну как, Алекс, ты дурак что ли? Вот тут сравнение, видишь: return a < b
. Дальше сам.»
Internal Server Error: /admin/shop/order/1019884/change/
TypeError at /admin/shop/order/1019884/change/
'<' not supported between instances of 'decimal.Decimal' and 'Decimal'
Traceback (most recent call last):
File "/usr/local/lib/python3.10/site-packages/django/core/handlers/exception.py", line 47, in inner
response = get_response(request)
File "/usr/local/lib/python3.10/site-packages/django/core/handlers/base.py", line 181, in _get_response
response = wrapped_callback(request, *callback_args, **callback_kwargs)
File "/usr/local/lib/python3.10/contextlib.py", line 79, in inner
return func(*args, **kwds)
File "/usr/local/lib/python3.10/site-packages/django/contrib/admin/options.py", line 616, in wrapper
return self.admin_site.admin_view(view)(*args, **kwargs)
File "/usr/local/lib/python3.10/site-packages/django/utils/decorators.py", line 130, in _wrapped_view
response = view_func(request, *args, **kwargs)
File "/usr/local/lib/python3.10/site-packages/django/views/decorators/cache.py", line 44, in _wrapped_view_func
response = view_func(request, *args, **kwargs)
File "/usr/local/lib/python3.10/site-packages/django/contrib/admin/sites.py", line 232, in inner
return view(request, *args, **kwargs)
File "/usr/local/lib/python3.10/site-packages/django/contrib/admin/options.py", line 1660, in change_view
return self.changeform_view(request, object_id, form_url, extra_context)
File "/usr/local/lib/python3.10/site-packages/django/utils/decorators.py", line 43, in _wrapper
return bound_method(*args, **kwargs)
File "/usr/local/lib/python3.10/site-packages/django/utils/decorators.py", line 130, in _wrapped_view
response = view_func(request, *args, **kwargs)
File "/usr/local/lib/python3.10/site-packages/django/contrib/admin/options.py", line 1540, in changeform_view
return self._changeform_view(request, object_id, form_url, extra_context)
File "/usr/local/lib/python3.10/site-packages/django/contrib/admin/options.py", line 1585, in _changeform_view
if all_valid(formsets) and form_validated:
File "/usr/local/lib/python3.10/site-packages/django/forms/formsets.py", line 496, in all_valid
return all([formset.is_valid() for formset in formsets])
File "/usr/local/lib/python3.10/site-packages/django/forms/formsets.py", line 496, in
return all([formset.is_valid() for formset in formsets])
File "/usr/local/lib/python3.10/site-packages/django/forms/formsets.py", line 321, in is_valid
self.errors
File "/usr/local/lib/python3.10/site-packages/django/forms/formsets.py", line 304, in errors
self.full_clean()
File "/usr/local/lib/python3.10/site-packages/django/forms/formsets.py", line 361, in full_clean
form_errors = form.errors
File "/usr/local/lib/python3.10/site-packages/django/forms/forms.py", line 170, in errors
self.full_clean()
File "/usr/local/lib/python3.10/site-packages/django/forms/forms.py", line 374, in full_clean
self._post_clean()
File "/usr/local/lib/python3.10/site-packages/django/forms/models.py", line 413, in _post_clean
self.instance.full_clean(exclude=exclude, validate_unique=False)
File "/usr/local/lib/python3.10/site-packages/django/db/models/base.py", line 1216, in full_clean
self.clean_fields(exclude=exclude)
File "/usr/local/lib/python3.10/site-packages/django/db/models/base.py", line 1258, in clean_fields
setattr(self, f.attname, f.clean(raw_value, self))
File "/usr/local/lib/python3.10/site-packages/django/db/models/fields/__init__.py", line 671, in clean
self.run_validators(value)
File "/usr/local/lib/python3.10/site-packages/django/db/models/fields/__init__.py", line 623, in run_validators
v(value)
File "/usr/local/lib/python3.10/site-packages/django/core/validators.py", line 358, in __call__
if self.compare(cleaned, limit_value):
File "/usr/local/lib/python3.10/site-packages/django/core/validators.py", line 392, in compare
return a < b
Exception Type: TypeError at /inside/shop/order/1019884/change/
Exception Value: '<' not supported between instances of 'decimal.Decimal' and 'Decimal'
Админка
Админка — это одна из киллер-фич. Forward declaration: в конце статьи я напишу, что с Джанго можно за 5 минут улететь в космос -, а тут добавлю, что у вас ещё будет отличная панель для управления всем. Вот только…
Кастомизация
Куча всего уже есть: генерируются формы для моделей, можете указать, какие поля где отображать, и прочее. Но ни шагу в сторону.
Как добавить свою кнопку с действием
Например, мне нужна кнопка для импорта из какого-то файла (да вообще всем нужна как правило).
Переписываете шаблон:
{% extends "admin/change_list.html" %}
{% block object-tools %}
{% block object-tools-items %}
{% if has_add_permission %}
-
Import
{{ block.super }}
{% endif %}
{% endblock %}
{% endblock %}
В ModelAdmin класс переопределяете get_urls
:
class SomeAdmin(admin.ModelAdmin):
def get_urls(self) -> List[str]:
return [
path(
'import/',
self.admin_site.admin_view(
self.import_view.as_view(
success_url=reverse_lazy(f'admin:app_model_changelist')
)
),
name='import',
),
*super().get_urls(),
]
Добавляете свой View, в нём пишете логику кнопки:
class ImportView(FormView):
form_class = ImportForm
template_name = 'admin/import.html'
def form_valid(self, form):
# ...
return super().form_valid(form)
Отлично, у вас вроде есть кнопка, но код раскидан тут и там, вы переопределили админ шаблон, добавили view, который не забыли обернуть в admin_view
… Сравните это с тем, как легко в django писать кастомные admin actions — вы просто пишете функцию и указываете её в actions = [...]
, и джанго дальше всё делает сам! Почему бы по аналогии не добавить настройки для кастомных кнопок на т.н. change list и change form странички?
Nested inlines
Есть inline, но как добавить вложенность, ну там inline в inline? В это трудно поверить, но если у вас есть иерархия «дом → квартира → жильцы», то менеджеры могут хотеть при редактировании дома добавить квартиру и сразу жильцов в ней. Начинайте откачивать разработчиков Джанго, они к такому не были готовы.
В джанго есть show_change_link
, который отобразит ссылку на новую страничку для редактирования, но для этого вы должны сначала сохранить текущую модель, что убивает весь UX.
Есть django-nested-inline, оно иногда работает, но количество звёзд и issues меня настораживает, и у меня есть негативный опыт работы с этим кошмаром.
Ах да, @danilovmy говорил, что можно включить inline-in-inline 4 сточками на питоне, но для этого нужно всего-то пожениться на джанговском javascript и всё там переписать.
Нет, спасибо, пусть чинят те, кто выбрал [object Object]
.
Optimistic lock
Его отсутствие испортит ваши данные. Я уже писал про это, но вкратце: ничто не защищает ваше приложение от того, что одну и ту же админ страничку откроют для редактирования разные менеджеры, и победят значения того, кто сохранит свои изменения позже другого.
Вы узнаете об этом потом, конечно же.
Object history
Джанго админка имеет прекрасную фичу: она сохраняет историю всех действий пользователей. Хех, неужели поверили, что всё так хорошо? Конечно, она сохраняет, кто и когда что-то сделал, но если вы решили узнать, что именно изменилось — да пошли вы! Зато эта фича включена по умолчанию. Но если она вам не нравится, то не волнуйтесь: все изменения, что прошли не через админку, никогда в этой истории не появятся.
Ещё раз: автоматически сохраняется бесполезная информация, и то не всегда. Браво!
Dashboard
Если вы с нашей планеты, то, скорее всего, вы организовываете всё как-то в своей голове. Ну например «магазин → продукты». Или там «дом → квартира → жильцы». К сожалению, когда создатели Джанго выходят на улицу, у них продукты лежат рядом с магазином, а квартиры находятся вне дома, и вокруг всего этого ходят жильцы. Иначе я не могу объяснить, почему админка не содержит вообще никакой иерархии и всё просто свалено в одну огромную неотсортированную кучу (вру, отсортировано по алфавиту, ха-ха, так что будут разделы «дом», «жильцы», «квартиры» — именно в таком порядке).
Смешались в кучу кони, люди, кампании, клиенты, емайлы…
Select2
Это такая штука, которая меняет UI для всяких полей, чтобы можно было фильтровать варианты по мере печати первых символов. Зачем? Ну например, если у вас 10 000 товаров, и где-то, хоть где-то у вас есть поле, где нужно выбрать один товар, то без select2 это отрендерится в поле с 10 000 вариантами, и всё будет страшно тормозить. Вы не поверите, но именно так по умолчанию это и работало в Джанго в течение долгого времени. Приятно полистать список из 10 000 товаров холодными осенними вечерами…
ORM
Мне кажется, они создавали ORM, когда sqlalchemy ещё не был так популярен, или стабилен, или ещё по какой-то причине. А потом уже не было дороги назад.
Немного за ORM
Не претендую на объективность, но мне кажется, что ORM — удобная штука. Теперь не нужно учить SQL, ведь ORM сам переведёт ваш python код в SQL-запрос.
Я вырос на этом. За всё время, как я пишу приложения на django, я не трогал чистый SQL ни разу до последнего времени, и есть в этом какая-то хрень. Каждый раз, как я встечал вопросы по SQL, я чувствовал себя не в своей тарелке, потому что синтаксис Джанго максимально далёк от SQL.
Ну, например, select_related
на самом деле делает JOIN
. А prefetch_related
не делает. Не всегда синтаксис django ORM выдерживает реальности SQL, и появляются всякие странные вещи типа OuterRef
, F
, Q
, и иже с ними. GROUP BY
вообще замаскирован.
Моё личное мнение: ORM должна быть маппингом объектов на реляционную БД (погодите, ведь это так и переводится!), а не полностью заменять язык SQL своим (не очень-то изящным) DSL.
Кстати, о изящности…
.filter ().filter () that span relations
Это мне снесло мозг, когда я впервые прочитал. Следите за руками:
Blog.objects.filter(id__gt=5, id__lt=10)
^-- Это выберет блоги с 5 < ID < 10.
Blog.objects.filter(id__gt=5).filter(id__lt=10)
^-- Это то же самое. Делаем первый фильтр, потом «усиляем» его последующим фильтром.
Теперь провернём то же, но с relations:
Blog.objects.filter(entry__headline__contains='Lennon', entry__pub_date__year=2008)
^-- Это выберет блоги, для которых есть Entry
, содержащие Lennon
и опубликованные в 2008. Логично.
Blog.objects.filter(entry__headline__contains='Lennon').filter(entry__pub_date__year=2008)
^-- Это то же самое Да хрен там! Это вернёт блоги, для которых есть Entry
, содержащие Lennon
, и при этом есть Entry
(не обязательно те же самые), опубликованные в 2008.
В принципе я вижу логику — каждый filter(...)
имеет как бы свой «scope», и первый фильтр ничего не знает о втором. Окей, я запомнил.
С exclude()
ведь так же?… Ха-ха!
Blog.objects.exclude(
entry__headline__contains='Lennon',
entry__pub_date__year=2008,
)
^-- Это исключит блоги, у которых есть Entry
, содержащие Lennon
, и при этом есть Entry
(не обязательно те же самые), опубликованные в 2008. То есть exclude()
один, но «scope» всё равно разные. Ааааа!…
Если вам нужен единый «scope», то используйте filter()
в exclude()
:
Blog.objects.exclude(
entry__in=Entry.objects.filter(
headline__contains='Lennon',
pub_date__year=2008,
),
)
^-- Это исключит блоги, для которых есть Entry
, содержащие Lennon
и опубликованные в 2008.
Пожалуйста, хватит.
Модели
Довольно спорный вопрос, который я озвучу так: что должно быть в модели, а чего не должно быть? Если лагерь тех, кто считает, что модель должна содержать только данные, связанные со структурой БД, а вся бизнес-логика должна жить отдельно. Если адепты fat models, которые, наоборот, всё стараются уместить в модели. Если люди с нетрадиционными взглядами, которые пишут бизнес-логику во views.
Но если на вопрос «что должно быть в модели» Джанго говорит «решайте сами», то вот на вопрос «что должно быть в полях модели» Джанго уже ответил за вас, и ответ этот странный.
Ну, например, в поле модели можно указать blank=True
, и тогда всякие формочки не позволят оставлять это поле пустым. С другой стороны, базе данных, для которой модель и написана, на этот атрибут плевать. То же про editable
. Получается, в модели вы пишите, как будет отображаться форма. Логика вроде есть, но с базой данных это не связано.
Choices
— та же тема: говорите, какие значения из ограниченного множества можно записывать в поле, а база данных кладёт на это болт. Опять же, фишка для форм.
Unique
— уникальность какого-то поля — вы можете указать и в поле, и в Meta
(но в meta вы можете указывать составное условие, а в поле не можете). То же самое и с db_index=True
.
У текстового поля есть max_length
, но БД плевать:
If you specify a
max_length
attribute, it will be reflected in theTextarea
widget of the auto-generated form field. However it is not enforced at the model or database level. Use aCharField
for that.
Dynamic choices
Ладно, если мы уж играем во всякие валидаторы и choices, то, раз это на уровне питона, это должно быть очень гибко? Ну там, например, я могу написать какой-нибудь код, чтобы, например, возможные варианты в одном поле зависели от другого?
Ээ… Нет. Choices статичны. ForeignKey.limit_choices_to
не зависит от объекта, то есть доступа к другим полям у него нет.
AUTH_USER_MODEL
В Джанго есть довольно деревянная модель для пользователей — User. Её можно достаточно просто заменить на свою при помощи AUTH_USER_MODEL
, но есть одно но: это нужно сделать в самом начале. Если сделать это позже, то может быть ситуация, когда предыдущие пользователи были в таблице auth_user, а новые должны быть в yourapp_user, и Джанго это не очень хорошо переваривает.
Ко всему прочему, стандартный User почему-то содержит username, который, как по мне, не так-то и нужен, когда есть email. Зато он не содержит, например, телефон, и если вы потом захотите добавить его, то придётся создавать дополнительную таблицу.
Короче, мне кажется, что предоставлять по умолчанию какого-то пользователя из django.contrib.auth — плохое решение, которое в долгосрочной перспективе аукнется, ведь всё равно его потребуется кастомизировать.
Content Types
Это такое встроенное django-приложение, которое позволяет в базе хранить название всех ваших моделей. Что это даёт? Например, Generic Relations.
Generic Relations — это удобная фича, если вы хотите поломать нормальную форму вашей базы. Например, вместо того чтобы иметь foreign key на какой-то ID из какой-то таблицы, вы теперь можете иметь «foreign key на любую таблицу». Под капотом это два поля: собственно content_type, который укажет, какую таблицу использовать, и object_id, который указывает на id из той таблицы.
Чуете, чем это плохо? Теперь что угодно может указывать на что угодно (что не вносит порядка в БД и код), но главное — база данных в шоке от ваших выкрутасов и не знает, как проверять целостность этого адища.
Я открою вам секрет: везде, где есть content type, можно без него, и это будет лучше. Вот тут неплохо написано про проблему и способы решения.
Ужасно то, что Content Type идёт в комплекте с джанго и про это пишут как про нормальное явление.
Ах да, на content types завязана система разрешений (permissions) в Джанго, поэтому их не выкинуть. Ура!
Миграции
Как писать миграции
Ткните мне в место документации, где написано, что код миграций (если у вас «data migration») должен быть максимально изолирован от кода приложения?
У Джанго каждая миграция имеет доступ к apps
и schema_editor
. Через apps
можно получить доступ к модели, как будто бы она из прошлого — времени миграции. И это логично, потому что модель могла поменяться, а вызывать какой-то код вы хотите не для текущего состояния, а для того, которое было.
Но никто, к сожалению, не говорит, что вам фактически нельзя импортировать вообще ничего из других модулей. По той же причине: в последующем коммите ваш код может поменяться, и импортировать вы будете уже совсем не то, что было на момент написания миграции.
# 0001_auto.py
from external import VALUE
def patch_values(apps, schema_editor):
MyModel = apps.get_model(...)
MyModel.objects.update(value=VALUE)
class Migration(migrations.Migration):
operations = [
migrations.RunPython(patch_values),
]
В одном коммите у вас может быть VALUE = 1
, и тогда миграция установит значение 1
. В следующем коммите VALUE = 2
, и та же миграция установит значение 2
.
Choices
Вот у вас есть CharField
, куда вы можете записать какую-то строку. На стороне Django вы можете ограничить, что именно туда можно записать — ну, например, красное
и белое
:
class Parent(models.Model):
class Name(models.TextChoices):
RED = 'красное'
WHITE = 'белое'
name = models.CharField('name', max_length=255, choices=Name.choices)
Но работает это только в админке и формах, а моделям в частности и БД в общем плевать на ваши ограничения — у БД есть знание про CharField
определённой длины, и она, сообветственно, позволяет записать туда всё что угодно до указанной длины, например, яумамыпрограммист
.
>>> from demo.models import *
>>> Parent.objects.first()
>>> p = Parent.objects.first()
>>> p.name
'красное'
>>> p.name = 'яумамыпрограммист'
>>> p.save()
>>> p.name
'яумамыпрограммист'
Но когда вы запустите миграцию, вы вдруг обнаружете, что choices там всё так же есть. И что самое интересное — при изменении choices появится и новая миграция, как будто в базе что-то изменилось. Но нет.
operations = [
migrations.AlterField(
model_name='parent',
name='name',
field=models.CharField(choices=[('красное', 'Red'), ('белое', 'White')], max_length=255, verbose_name='name'),
),
]
Chunking, zero downtime
В это сложно поверить, но иногда в базе данных может быть больше 100 строк.
Ну, например, на одном проекте у меня 100 миллионов. Просто добавить туда колонку уже занимает порядочно времени. Иногда мне нужно запускать там data migrations, а всё, что Джанго может — копать или не копать запустить миграцию в транзакции или без неё. На больших данных спасает только обновление чанками, но в Джанго для этого ничего нет.
CREATE INDEX CONCURRENTLY
тоже нет, вы сомневались?
lambda
Нельзя сериализовать лямбду в миграции:
class School(models.Model):
name = models.CharField(..., default=lambda: random.choice(names)) # FUCK YOU!
Но функцию можно:
def random_name():
return random.choice(names)
class School(models.Model):
name = models.CharField(..., default=random_name) # OKAY :)
А зачем вообще сериализовать callable default в миграции?… Кстати,
callable default
…не принимает аргументов, поэтому есть только два юз-кейса, когда это нужно: random и datetime.now, который и так уже есть в виде auto_now_add. Никаких «значение по умолчанию в зависимости от других полей» нет!
sqlalchemy
Каждый, с кем я общался, говорит, что в sqlalchemy написать какой-нибудь мозговыносящий запрос намного проще, чем в django ORM. Alembic более конфигурируем. Sqlalchemy позволяет использовать атрибуты как имя поля в запросе. SQLalchemy имеет разделение на core и orm. SQLalchemy, чёрт возьми, поддерживает асинхронность. Учитывая, что алхимию я почти не трогал в своей жизни, я боюсь даже представить, сколько там ещё всего. Ну, например, алхимия умеет проверять, что соединение с БД действительно работает. Если бы не один фатальный недостаток.
Meta
В каждой модели вы можете использовать специальный класс Meta
:
from django.db import models
class School(models.Model):
# ...
class Meta:
ordering = ['pk']
verbose_name = 'school'
verbose_name_plural = 'schools'
constraints = [
# ...
]
Meta.ordering
ordering
задаёт сортировку по умолчанию, что чревато. С одной стороны, ordering
цепляется везде и по умолчанию будет именно эта сортировка. С другой стороны, бывают моменты, когда эта сортировка вам не нужна, и важно не забыть её «отменить» при помощи order_by()
.
Явное лучше неявного, и я считаю, что задавать сортироку явно в запросах — лучше, чем ожидать, что к каждому запросу автоматически применится сортировка.
Meta: класс в классе
Класс Meta
определяется «внутри» класса модели. С одной стороны, Meta
как бы «принадлежит» классу модели и определяет его «мета-свойства».
С другой стороны, в питоне «внутренний» класс является вполне самодостаточной сущностью и не имеет никакого доступа ко «внешнему» классу. Добавляя конкретики: из Meta
вы не можете сослаться ни на одно из полей модели.
Из всего вышеперечисленного в том числе следует, что если у вас есть наследование классов, то в некоторых случаях Meta должно быть унаследовано явно.
Строки вместо атрибутов
Именно из-за пункта выше вы не можете написать
class School:
class Meta:
ordering = [School.id]
constraints = [
UniqueConstraint(fields=[School.name], name='unique_school_name'),
]
Да и вообще если посмотреть на Джанго, то там очень любят строки и магические методы для обращения к атрибутам модели:
School.objects.values_list('id', 'name')
# а не School.objects.values_list(School.id, School.name)
School.objects.filter(name__iconstains='a')
# а не School.objects.filter(School.name.icontains('a'))
Я ярый противник строк и словарей и всегда стараюсь использовать атрибуты класса, константы, NamedTuple
или dataclass
.