Окей, Джанго, у меня к тебе несколько вопросов

4cafa79551cddfe4cc85d019c512db59.jpg

Недавно я проходил очередное интервью, и меня спросили, пишу ли я на 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ая ошибка, например:

80b128d717fcfe36cdb4bf20a8b7b1ae.jpg5008546b9d374c5267ce583dbcf78dfd.jpg

Джанго как бы говорит: «эй, чел, ошибка в валидаторе какого-то поля, ты сравниваешь строку и число, а дальше…»

12b928dbb7271d7769fa59fcb2721ac3.jpg

Error reports

Без сарказма, идея отправлять ошибки на email администратора — гениально! Это реально удобно, особенно когда какой-нибудь сервис для отлова ошибок ещё не прикручен. Проблема только одна: ошибки не группируются. Не дай бог вам сделать где-то ошибку и задеплоить её — на каждый вызов ошибки вам прилетит письмо на почту, и скоро ваш ящик превратится в помойку.

Чтобы вышеупомянутый пункт реально вас порадовал, разработчики Джанго сделали так, что invalid host header — то есть несоответствие header Host: xxx.com какому-то домену из ALLOWED_HOSTS — вызвает ошибку 500. Логика, наверно, такая: раз какой-то хрен с горы указал неправильный заголовок, то ваш сайт должен упасть, а админу должно прийти уведомление. Неплохо, Джанго!

Вот моя почта:

2d18b748ddf6767c6f94028aa0a2ff56.jpg

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, и хотел это предотвратить.

Не вышло.

3bcc0ad77f7aa3bbeafe7d17d121c298.jpg

Когда я только пришёл с php, где было нормой открывать соединение к БД прямо в шаблоне и в шаблоне же что-то считать и ставить куки, я был поражён архитектурой MVT в Django — то есть можно разделить модели, обработку запроса и рендеринг! И всё бы тут хорошо, но вот шаблоны… Если в php была одна крайность — я мог сделать всё в шаблоне — то тут я мог сделать чуть больше, чем ничего: отобразить переменную, атрибут объекта или вызвать его метод (но только без аргументов!), или преобразовать что-то во что-то при помощи фильтра (но только 1 аргумент), или запилить templatetag, если нужно >1 аргументов.

Слишком много вопросов:

  1. Почему template filter принимает только один аргумент?

  2. Почему я должен регистрировать фильтр? Оно может само?

  3. Почему если два аргумента, то уже template tag?

  4. Почему включение одного шаблона в другой скрывается в inclusion tag, вместо того, чтобы делать это явно в шаблоне? Я ведь из шаблона вызываю код. который рендерит ещё шаблон.

  5. Зачем takes_context, если можно передать нужные переменные явно?

  6. Зачем сплиттить на django.template.Node, а потом джойнить обратно?

  7. …и так до бесконечности.

Есть 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-формы.

` `

Что? Где именно ошибка? Ну вот тут, смотри, я что-то пытаюсь перевести в строку и падаю:

85def856fc056c8bc86796f5fd51e701.jpg25502e9e6e5d9df7992098d05977a7ff.jpg

Ниже одно из моих любимых: «Чувак, ты не можешь сравнить 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? В это трудно поверить, но если у вас есть иерархия «дом → квартира → жильцы», то менеджеры могут хотеть при редактировании дома добавить квартиру и сразу жильцов в ней. Начинайте откачивать разработчиков Джанго, они к такому не были готовы.

915a9cd74b22a201b407d21f2be103a3.jpg

В джанго есть show_change_link, который отобразит ссылку на новую страничку для редактирования, но для этого вы должны сначала сохранить текущую модель, что убивает весь UX.

Есть django-nested-inline, оно иногда работает, но количество звёзд и issues меня настораживает, и у меня есть негативный опыт работы с этим кошмаром.

Ах да, @danilovmy говорил, что можно включить inline-in-inline 4 сточками на питоне, но для этого нужно всего-то пожениться на джанговском javascript и всё там переписать.

ac851e5be77178acad5d19a698b4d6d2.jpg

Нет, спасибо, пусть чинят те, кто выбрал [object Object].

Optimistic lock

Его отсутствие испортит ваши данные. Я уже писал про это, но вкратце: ничто не защищает ваше приложение от того, что одну и ту же админ страничку откроют для редактирования разные менеджеры, и победят значения того, кто сохранит свои изменения позже другого.

Вы узнаете об этом потом, конечно же.

Object history

Джанго админка имеет прекрасную фичу: она сохраняет историю всех действий пользователей. Хех, неужели поверили, что всё так хорошо? Конечно, она сохраняет, кто и когда что-то сделал, но если вы решили узнать, что именно изменилось — да пошли вы! Зато эта фича включена по умолчанию. Но если она вам не нравится, то не волнуйтесь: все изменения, что прошли не через админку, никогда в этой истории не появятся.

f40379aef8756ec4c26ddd573d6440e9.jpg

Ещё раз: автоматически сохраняется бесполезная информация, и то не всегда. Браво!

Dashboard

Если вы с нашей планеты, то, скорее всего, вы организовываете всё как-то в своей голове. Ну например «магазин → продукты». Или там «дом → квартира → жильцы». К сожалению, когда создатели Джанго выходят на улицу, у них продукты лежат рядом с магазином, а квартиры находятся вне дома, и вокруг всего этого ходят жильцы. Иначе я не могу объяснить, почему админка не содержит вообще никакой иерархии и всё просто свалено в одну огромную неотсортированную кучу (вру, отсортировано по алфавиту, ха-ха, так что будут разделы «дом», «жильцы», «квартиры» — именно в таком порядке).

Смешались в кучу кони, люди, кампании, клиенты, емайлы...Смешались в кучу кони, люди, кампании, клиенты, емайлы…

Select2

Это такая штука, которая меняет UI для всяких