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

в 5:16, , рубрики: django, python, леонардо дикаприо, негр в синем, Программирование
Окей, Джанго, у меня к тебе несколько вопросов - 1

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

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

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

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

Error reports

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

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

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

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

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

Не вышло.

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

Когда я только пришёл с 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-формы.

`
`

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

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

Ниже одно из моих любимых: "Чувак, ты не можешь сравнить 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 <listcomp>
 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 %}
  <ul class="object-tools">
    {% block object-tools-items %}
      {% if has_add_permission %}
        <li>
            <a href="{{ import_url }}" class="addlink">Import</a>
        </li>
        {{ block.super }}
      {% endif %}
    {% endblock %}
  </ul>
{% 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? В это трудно поверить, но если у вас есть иерархия "дом -> квартира -> жильцы", то менеджеры могут хотеть при редактировании дома добавить квартиру и сразу жильцов в ней. Начинайте откачивать разработчиков Джанго, они к такому не были готовы.

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

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

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

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

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

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

Optimistic lock

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

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

Object history

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

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

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

Dashboard

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

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

Select2

Это такая штука, которая меняет UI для всяких <select> полей, чтобы можно было фильтровать варианты по мере печати первых символов. Зачем? Ну например, если у вас 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.

Пожалуйста, хватит.

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

Модели

Довольно спорный вопрос, который я озвучу так: что должно быть в модели, а чего не должно быть? Есть лагерь тех, кто считает, что модель должна содержать только данные, связанные со структурой БД, а вся бизнес-логика должна жить отдельно. Если адепты 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 the Textarea widget of the auto-generated form field. However it is not enforced at the model or database level. Use a CharField 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()
<Parent: Parent object (1)>
>>> 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__icontains='a')
# а не School.objects.filter(School.name.icontains('a'))

Я ярый противник строк и словарей и всегда стараюсь использовать атрибуты класса, константы, NamedTuple или dataclass. Но Джанго заставляет меня плакать.

Custom Meta

Meta кажется отличным местом, чтобы дописать туда свои, кастомные мета-свойства модели. Я туда писал список полей, по которым делать поиск и сравнивать модели. Прекрасно то, что класс Meta "злой полицейский" и пошлёт вас при малейшей попытке передать туда что-то неожиданное. С другой стороны, ModelAdmin, например, "добрый полицейский" и прекрасно принимает inline вместо inlines. Почему? Because fuck you, that's why.

Temporary fields

Иногда удобно сначала "наполнить" модель временными значениями, а потом их как-то сохранить. Это пригождается, если вы юзаете что-то вроде паттерна "command" - где-то создаётся временный инстанс модели, передаётся куда-то, и там с ним что-то делается.

Удобно это потому, что в модели чётко определены поля, вам не нужно писать свой dataclass и там хранить временные значения. Так вот, благодаря вышеупомянутым пунктам, вы можете что угодно записать в поля временной джанго модели.

m2m

Во все поля, даже в ForeignKey.

Но не в ManyToManyField. Потому что там присваивание делает запись в базу данных, прям сразу. Но это уже deprecated.

А как сделать копию?

Ну, то есть хочу продублировать строчку в БД.

Что? Нет, нету никакого метода instance.copy(), это ж Джанго! Вот каноничный способ:

instance.id = None
instance.save()  # теперь в instance хранится копия

Ах да, забыл сказать - это не deepcopy.

Сигналы

В джанго есть специальный функционал для запутывания кода, и называется он сигналы. Он позволяет расп___расить линейную логику вашего кода и добавить неопределённости.

Как это делается? Добавляются функции-обработчики, которые могут быть вызваны на определённые события.

Если вы используете сигналы, то вот вам вопросник:

  • Когда выполняется сигнал - сразу после совершения события, или после выполнения функции, или после успешного коммита в БД, или как повезёт?

  • Какой порядок выполнения двух сигналов, слушающих одно и то же событие?

  • Как откатить транзакцию, если сигнал упал?

  • Как в отчёте об ошибке посмотреть содержимое функции, повлёкшей событие, если сигнал упал?

И более того, ваши сигналы не вызовутся при update() или bulk_create(). Но сигналы pre/post_delete вызовутся. Но метод delete() не вызовется.

Note that the delete() method for an object is not necessarily called when deleting objects in bulk using a QuerySet or as a result of a cascading delete. To ensure customized delete logic gets executed, you can use pre_delete and/or post_delete signals. Unfortunately, there isn’t a workaround when creating or updating objects in bulk, since none of save(), pre_save, and post_save are called. --- docs

Changes

Очень часто нужно узнать, что изменилось в модели после save(). Пошли вы!

Кстати, в сигнале post_save есть флаг created, а в pre_save нет. Так интереснее.

Created vs default

Есть в Джанго каноничный способ узнать, сохранена модель или нет - проверка instance.id. Если там None, то модель ещё не сохранена. Но это не работает, если у вас в поле id есть default (например, uuid4), потому что он ставится при инициализации модели, а не при сохранении, то есть в id никогда не будет None.

Instance vs Batch, or Method vs Query

Тут, признаюсь, спорно, но меня не покидает чувство "неправильности", и я включил этот пункт сюда.

Итак. Есть два варианта писать код. Можно думать "напишу для одного" и отмасштабирую по мере необходимости, а можно думать "напишу для тысячи, и использую пока что для одного". Джанго толкает разработчиков думать "по-единично", "per instance". Вот смотрите.

Как сделать что-то после сохранения объекта? Переопределить model.save() или использовать сигналы. Как сделать что-то после сохранения объектов? Да никак, писать свою функцию и вызывать её.

Как в админке изменить один объект? Да просто, заходите на страничку объекта и сохраняете нужные изменения. Как в админке изменить несколько объектов? Да никак, писать свою функцию и добавлять её в admin_actions.

В админке у вас 25 штук по умолчанию. Джанго понимает, если в list_display метод/поле модели или админ класса, но не понимает, если там поле из annotate / aggregate.

В Джанго возведено в абсолют, что есть объект, который отображает строку из базы данных, и у него есть куча методов с python кодом. Что, если у вас 1000 строк? Да ничего хорошего - либо будет 1000 объектов и 1000 вызовов методов, либо пишите какую-то отдельную логику для batch processing.

Слишком часто я находил себя в ситуации, когда я написал логику для модели, а потом её же вынужден копировать в QuerySet. Например, написал какой-то хитрый калькулятор статуса для модели: def get_status(). А потом понадобилось этот статус отобразить для, например, 25 объектов со странички админки - и чтобы это всё не тормозило, я пишу кастомный QuerySet с def with_status(), который при помощи SQL аннотирует поле status, всякие там Case() и прочее.

def with_status(self):
        return self.annotate(
            num_approved_prospects=Sum(Case(When(prospects__is_approved=True, then=1), default=0)),
            num_prospects=Count('prospects'),
            num_validated_emails=Sum(Case(When(prospects__emails__is_valid=True, then=1), default=0)),
            num_emails=Count('prospects__emails')
        ).annotate(
            status=Case(
                When(created__lte=now() - settings.CAMPAIGN_OBSOLENCE_PERIOD, then=Value("Obsolete")),
                When(num_prospects__gt=0, num_approved_prospects=0, then=Value("Pending Prospects Approval")),
                When(num_emails__gt=0, num_validated_emails=0, then=Value("Pending Emails Validation")),
                default=Value("OK")
            )
        )

А чтобы DRY, я переопределяю def get_status через with_status:

def get_status(self):
	return self.__class__.objects.filter(pk=self.pk).with_status().values_list('status', flat=True)[0]

Тупость какая-то, но тупость быстрая. Такое часто встречается, и джанго как-то не очень мне помогает писать красивый и одновременно быстрый код.

Slice & pagination

В Джанго вы можете взять слайс от QuerySet, типа так:

Photo.objects.all().order_by('id')[10:20]

Под капотом это limit-offset. Limit-offset плохо, когда у вас большой offset, потому что приходится делать скан всего оффсета. Плюс ко всему это undefined behavior, если вы забыли сделать order_by. Плюс ко всему это может работать неправильно, если в процессе пагинации добавляются новые записи.

В этом случае спасают курсоры, или пагинация по первичному ключу. Теперь угадайте, как в джанго устроена пагинация?

Где мои курсоры?

Да и вообще в Джанго как-то не принято думать о больших объёмах. Если у вас 100 миллионов объектов, то админка затормозит, потому что там в пагинации вызывается Model.objects.count() и честно пытается посчитать 100 миллионов строк.

Итерация по queryset тормозит, хотя вроде бы iterator() был как раз для таких случаев: https://stackoverflow.com/questions/4222176/why-is-iterating-through-a-large-django-queryset-consuming-massive-amounts-of-me

Зато знаете, где пагинация по id всё-таки есть? Правильно, в django-rest-framework.

Get бесполезен

Есть Photo.objects.filter(id=5).first(), который вернёт объект или None - удобно. Проверять на None проще, чем

try:
    instance = Photo.objects.get(id=5)
except Photo.DoesNotExist:
    # handle this case
except MultipleObjectsReturned:
    # fuck

MultipleObjectsReturned бесполезен, потому что говорит, что вы ожидали один объект, а получили много, но не говорит, какие именно объекты вернулись, а с вероятностью 99% вам это нужно знать. Плюс ко всему MultipleObjectsReturned означает, что вы прое... что кто-то забыл написать unique constraint. Например, смотрите следующий пункт.

Uniqueness on nullable

Так уж устроены БД, что вы можете поставить unique constraint на nullable поле. В большинстве случаев это не то, что вы хотели сделать. Смотрите сами:

You can insert NULL values into columns with the UNIQUE constraint because NULL is the absence of a value, so it is never equal to other NULL values and not considered a duplicate value. This means that it's possible to insert rows that appear to be duplicates if one of the values is NULL .

И я был бы очень рад, если бы при попытке создания такого unique constraint вылезал хоть какой-нибудь warning типа "Алексей, ты чёрт? Что творишь?". Но нет.

Instance comparison

Два инстанса по умолчанию сравниваются по id. Я не могу придумать юз-кейс, когда это нужно. Сравнение по всем полям кажется мне более логичным. Сравнение по id отлично заменяется is.

Parametrized related name

Да, когда вы пишете related_name='%(model)s', а модель называется MySuperLongModelName, то related_name станет mysuperlongmodelname, спасибо за читаемость. Расскажите им про функции типа pascalize, которые могут вывести my_super_long_model_name.

Model managers

Есть такая штука: ModelManager.

Зачем эти менеджеры? Хех, ну потому что создатели джанго тогда ещё не дочитали документацию питона до раздела про @classmethod. Бывает!

BigAutoField

Потому что разрабы джанго прочитали о законе Мура, всё удваивается постоянно, ipv4 кончаются, а вдруг ID тоже закончатся? Поэтому влупашим не 2147483647, а 9223372036854775807 как лимит ID. Всем! Ну да, чуть больше места занимает, чуть медленнее, но зато Мур побеждён.

Atomic requests

ATOMIC_REQUESTS заключает каждый запрос к сайту в транзакцию. Мне удалось заполучить секретные кадры с совещания разработчиков Джанго, когда они обсуждали эту опцию:

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

Permissions

C[R]UD

До недавнего времени было:

  • Create permission

  • Read permission Fuck you!

  • Update permission

  • Delete permission

К счастью, это пофиксили, и теперь есть permission на просто чтение. Ну забыли, чо!

Settings

А давайте запишем всё вместе в один файл?

Если вы откроете случайный джанго проект, найдте в нём settings.py (если он там только один, то вам повезло), то увидите там примерно то же, что и в домашней папке линукса:

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

Каждая софтина стремится закинуть свою папку вам в home, чтобы заявить о своём присутствии. В джанго та же философия - каждое приложение заставляет что-нибудь дописывать в settings.py, и в итоге получается длинное полотно всех настроек в одном файле.

А почему, собственно, один файл?

Добавим энтропии! Теперь можно иметь несколько файлов настроек, и даже импортировать одни файлы в другие, перезаписывая переменные. Джанго подстрекает на это - иметь не-единые файлы настроек - ведь есть DJANGO_SETTINGS_MODULE env var, а при запуске можно manage.py runserver --settings ....

Не нужно нам никакой организации

Это вам не .ini какой-нибудь, так что тут нет секций. Это вам не .yaml какой-нибудь, так что тут нет иерархии. Это .py файл, настоящий питоновский модуль, где вам доступна вся мощь языка, датаклассы там, пидантики всякие и прочее! Поэтому настройки будем писать в виде "ключ = значение", и только так:

SECRET_KEY = '12345'
DEBUG = True
CELERY_BROKER_URL = ''
CELERY_RESULT_BACKEND = ''
CELERY_RESULT_EXPIRES = int(timedelta(days=1).total_seconds())
CELERY_COMPRESSION = 'gzip'
CELERY_MESSAGE_COMPRESSION = 'gzip'
CELERY_SEND_EVENTS = True

Ну, окей - иногда можно использовать dict. Но только не dataclass или named tuple!

Первое правило джанго: ссылаться на всё при помощи строк!

Потому что передача модулей в настройки может случайно облегчить жизнь вашей IDE, и jump to definition может вдруг заработать. Ну его, от греха подальше!

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    'django_extensions',
    'django_probes',
    'rest_framework',

    'project.core',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

А импортировать будем вообще что-то другое

Вот ваш модуль настроек: project/core/settings.py, в нём есть настройка DEBUG = True. Вот вы его импортируете в джанго: from django.conf import settings; print(settings.DEBUG). IDE смотрит на вас с упрёком и не понимает, чё с этим делать и как автодополнять.

Env vars для слабаков

Как только вы создадите новый проект на Джанго, он любезно создаст вам settings.py и подставит за вас значения в DEBUG и SECRET_KEY, как бы говоря: "чувак, всё круто, я уже сгененрировал значения для тебя, мы уже почти в продакшене, коммить в репу секретный ключ скорей".

Так, стоп! Давайте сначала.

Есть 12 factor apps, который говорит: юзайте env vars для конфигурирования приложений. Я вывел для себя такую логику: в .env файл (который не под VCS) я кладу секретные ключи, а также те настройки, которые меняются от одной машины к другой (то есть чтобы запустить локально, на сервере A или на сервере B, я просто подсовываю соответствующий .env файл, который заточен под то конкретное окружение). Соответственно, в settings.py у меня два класса настроек:

  1. импортированные env vars (с конвертацией типа), чтобы я мог их использовать в любом месте приложения: DEBUG = env.bool('DEBUG', default=False)

  2. другие настройки, которые специфичны для данного приложения и не меняются при смене среды - ну там I18N или INSTALLED_APPS.

И я вот это всё настраиваю, а в джанго предлагают просто фигачить всё в settings.py, а то и в несколько разных: settings.dev.py, settings.prod.py - ведь зачем-то они поддерживают DJANGO_SETTINGS_MODULE, позволяющий переключать файл настроек. Спасибо, конечно, но нет.

App config

Я пытался, ребята. Я пытался. Но я так и не смог понять, нафига нужны AppConfig.

Сначала я прочитал вот эту статью, где какой-то почтенный Мухаммед долго рассказывает, как эти АппКонфиги работают, и смысл их писать есть только для того, чтобы переопределить метод ready(), который переопределит настройки этого приложения... чтоа? Нет, серьёзно, так и пишет:

The key reason to define a custom AppConfig class is to implement the ready() method, that has been declared in django.apps.Appconfig class. When Django loads the project and its apps, the ready() method is called on the AppConfig instnace of each app. Hence we can use the ready() method to change any of our app’s existing setup or to add to it.

Ну это где-то за гранью моего разума, поэтому лучше почитаю дальше пример...

Conclusion Apps and AppConfig are powerful tools.

А, уже вывод. Ну ладно.

К чёрту этот медиум, старый добрый стэковерфлоу мне поможет:

Purpose of apps.py file: This file is created to help the user include any application configuration for the app. Using this, you can configure some of the attributes of the application.

Ладно, App Config - это место, где я могу указать какие-то настройки приложения. Какие настройки? Пусть ответит Джанго:

Application configuration objects store metadata for an application. Some attributes can be configured in AppConfig subclasses. Others are set by Django and read-only.

App Config хранит какие-то метаданные:

  • name == Full Python path to the application, e.g. 'django.contrib.admin'

  • label == Short name for the application, e.g. 'admin'

  • verbose_name == Human-readable name for the application, e.g. “Administration”

  • path == Filesystem path to the application directory, e.g. '/usr/lib/pythonX.Y/dist-packages/django/contrib/admin'

  • default == Set this attribute to False to prevent Django from selecting a configuration class automatically. This is useful when apps.py defines only one AppConfig subclass but you don’t want Django to use it by default.

  • default_auto_field == The implicit primary key type to add to models within this app. You can use this to keep AutoField as the primary key type for third party applications.

Всё из вышеперечисленного звучит как чертовски важные вещи, без которых я даже не знаю как жил раньше, буду теперь везде переопределять и радоваться.

Пример в официальной документации доставляет - типа, можете использовать эту штуку, чтобы поменять название какого-то приложения:

If you’re using “Rock ’n’ roll” in a project called anthology, but you want it to show up as “Jazz Manouche” instead, you can provide your own configuration:

# anthology/apps.py
from rock_n_roll.apps import RockNRollConfig

class JazzManoucheConfig(RockNRollConfig):
    verbose_name = "Jazz Manouche"

# anthology/settings.py
INSTALLED_APPS = [
    'anthology.apps.JazzManoucheConfig',
    # ...
]

Я долго думал и наконец придумал: ха-ха, захочу две админ панели на сайте разместить - тут-то мне и пригодятся эти AppConfig, бахну сразу парочку, поменяю им названия на разные, и будет два приложения и две админки. Потом открыл мануалы, а там эти App Config и не нужны. Ну не знаю тогда. Подскажите, кто в теме.

А ещё было бы здорово иметь всякие разные настройки для конкретного приложения прям в этих AppConfig, а не просто складывать всё в settings.py. Ну да, ну да, пошёл я...

manage.py

Commands

В джанго есть т.н. management commands, то есть вы можете написать какой-то скрипт и вызывать его как CLI при помощи ./manage.py my_command. Если скрипт выходит большой или есть необходимость его переиспользовать (вызывать в других частях проекта), то есть смысл вынести его в отдельную функцию. сделав что-то вроде "thin management command":

# app/management/my_command.py
from django.core.management.base import BaseCommand, CommandError
from app.module import big_function_1000_lines_of_code

class Command(BaseCommand):
	def add_arguments(self, parser):
        parser.add_argument('poll_ids', nargs='+', type=int)

    def handle(self, *args, **options):
        big_function_1000_lines_of_code(option['poll_ids'])

Это частый юз-кейс. Именно поэтому нет никакого функционала, чтобы (полу)автоматически сконвертировать функцию в management command. Видите, в примере сверху я как дурак определяю аргументы в add_arguments, и потом просто их передаю в big_function_1000_lines_of_code. Когда там аргументов, скажем, 4, это начинает раздражать.

Вообще Джанго как-то настраивает вас писать код прям в management command, а чтобы его переиспользовать, можно вызвать management команду прмо из кода. Ещё раз: код вызывает CLI команду, которая вызывает код.

Overriding commands

Как-то ночью я не спал, думал о великом, и тут мне пришла идея: а что если я перепишу какую-нибудь встроенную команду Джанго? Ну там напишу приложение, которое будет делать что-то полезное, и заменит, скажем, команду collectstatic. Чтоб вы понимали: ./manage.py collectstatic собирает всякие картинки, js и css из приложений и кладёт их в одно место - либо в папку, либо, например, в s3, и, как по мне, почти все джанго-проекты эту команду используют (хотя бы потому, что для джанго-админки нужны эти самые js и css). То есть collectstatic обычно вызывается при каждом деплое.

А теперь я пишу приложение, которое содержит в себе эту команду, и перезаписывает стандартную команду, добавляя свой payload:

from django.core.management.base import BaseCommand
from django.core import management
from django.contrib.staticfiles.management.commands.collectstatic import Command as OriginalCommand

class Command(BaseCommand):
    def handle(self, *args, **options):
        management.call_command(OriginalCommand())
        self.stdout.write(self.style.SUCCESS('Вы лох'))
(venv) src (master) [1]> ./manage.py collectstatic

You have requested to collect static files at the destination
location as specified in your settings:

    /home/user/workspace/app/src/static

This will overwrite existing files!
Are you sure you want to do this?

Type 'yes' to continue, or 'no' to cancel: yes

0 static files copied to '/home/user/workspace/app/static', 160 unmodified.
Вы лох

Вы никогда это не найдёте без анализа исходников, потому что даже если будете пошагово отлаживать моё приложение и смотреть, что оно делает в процессе работы, то ничего подозрительного не будет - ведь payload вызывается только из CLI и только по определённой команде.

Справедливости ради стоит отметить, что это будет работать только если моё злое приложение находится перед перезаписываемым приложением - ну типа так:

INSTALLED_APPS = [
    'my_evil_app',
    # ...
    'django.contrib.staticfiles',
    # ...

Для этого можно попросить в README ставить приложение перед остальными, и дело пучком. Джанго не предупреждает о перезаписи команды, просто выполняет и всё. Я сельский дурачок, поэтому мечтаю, чтобы любая коллизия разрешалась как-то явно (./manage.py my_evil_app.collectstatic и ./manage.py staticfiles.collectstatic), а при ./manage.py collectstatic оно бы падало или ругалось - но это я. Окей, Джанго, тебе виднее.

URLs

Path vs pattern

Можно писать path-style:

path('articles/<int:year>/<int:month>/<slug:slug>/', views.article_detail),

Можно regex-style:

re_path(r'^articles/(?P<year>[0-9]{4})/(?P<month>[0-9]{2})/(?P<slug>[w-]+)/$', ...)

Ну для энтропии.

APPEND_SLASH

Вроде как удобно - есть пользователи, которые не ставят / в конце URL, и при APPEND_SLASH = True джанго это сделает за них. А потом внезапно вы узнаёте, что это не работает с POST. И не просто не работает: если пользователь отправит POST-запрос без слэша в конце, то окей, джанго, ты не можешь сделать редирект, так напиши как есть: URL не найден, 404 ошибка. Но знаете что? Джанго падает с 500 ошибкой и RuntimeError, и говорит: "знаешь, я облажался, если ты не хочешь, чтобы это повторилось - просто выключи APPEND_SLASH".

Django version 3.2.8, using settings 'project.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
ERROR 2021-11-24 14:40:10,234 django.request Internal Server Error: /api/auth/login
Traceback (most recent call last):
  File "/home/user/workspace/.../venv/lib/python3.9/site-packages/django/core/handlers/exception.py", line 47, in inner
    response = get_response(request)
  File "/home/user/workspace/.../venv/lib/python3.9/site-packages/django/utils/deprecation.py", line 116, in __call__
    response = self.process_request(request)
  File "/home/user/workspace/.../venv/lib/python3.9/site-packages/django/middleware/common.py", line 54, in process_request
    path = self.get_full_path_with_slash(request)
  File "/home/user/workspace/.../venv/lib/python3.9/site-packages/django/middleware/common.py", line 88, in get_full_path_with_slash
    raise RuntimeError(
RuntimeError: You called this URL via POST, but the URL doesn't end in a slash and you have APPEND_SLASH set. Django can't redirect to the slash URL while maintaining POST data. Change your form to point to 127.0.0.1:8000/api/auth/login/ (note the trailing slash), or set APPEND_SLASH=False in your Django settings.

Static / media

Рубрика "найдите отличия":

Static files:

  • Это просто файлы

  • Собираются в специальную папку

  • Обычно отдаются напрямую сервером, а не самим джанго

  • Автоматически отдаются самим Джанго, когда DEBUG=True

Media files:

  • Это просто файлы

  • Собираются в специальную папку

  • Обычно отдаются напрямую сервером, а не самим джанго

  • Автоматически не отдаются самим Джанго, когда DEBUG=True

Views

as_view() as_view() as_view() as_view() as_view()

Когда вы передаёте CBV в какой-то url, вы должны не забыть вызвать as_view(), потому что джанго сама не может:

urlpatterns = [
    path('properties/', PropertyListView.as_view()),
    path('properties/<int:pk>/', PropertyDetailView.as_view()),
    path('listings/<int:pk>/', ListingDetailView.as_view()),
    path('listings/<int:pk>/do/', DoView.as_view(), name='do-listing'),
    path('do/', DoView.as_view(), name='do'),
    path('model/<int:model_version>/', ModelView.as_view(), name='model'),
]

MVT, fuck you!

Urlpatterns - файл, где вы перечисляете все url вашего приложения, и больше, в общем-то, ничего. Но иногда можно расслабиться, плюнуть на MVT и указать шаблон прямо в urlpatterns. Ведь правила созданы, чтобы их нарушать!

urlpatterns = [
    path('about/', TemplateView.as_view(template_name="about.html")),
]

Декораторы + CBV = ?

Нельзя просто взять и задекорировать CBV. Первым делом создатели джанго советуют задекорировать view прямо в urlconf. Если вам понравился пункт выше про MVT и вы прям совсем шалун, то этот сниппет кода для вас:

urlpatterns = [
    path('about/', login_required(TemplateView.as_view(template_name="secret.html"))),
    path('vote/', permission_required('polls.can_vote')(VoteView.as_view())),
]

А если вы не шалун, то есть костыль:

@method_decorator(never_cache, name='dispatch')
@method_decorator(login_required, name='dispatch')
class ProtectedView(TemplateView):
    template_name = 'secret.html'

Вывод

Джанго по-прежнему фреймворк, на котором можно за 5 минут построить ракету и улететь в космос - что я и делаю на работе вот уже лет 10 - но он мне порой сильно не нравится. Да, разумеется, все проблемы решаемы - там пакет доставить, тут настройки изменить, тут просто избегаешь каких-то вещей - и вроде всё работает как надо.

Но претензии две:

  1. "Django. Batteries included" - вроде так, но батарейки-то из FixPrice и надолго их не хватит. В отличие от фласка, где "собери всё сам", в Джанго идеология типа "оно уже включено и работает", но часто включено не то и работает не так.

  2. Иногда они создают какую-то дичь, пишут о ней в документации, из-за популярности Джанго туда набегает куча народу и все они думают, что так и надо, и так и пишут. Документация Джанго - это не best practices, и своя голова на плечах всё ещё очень нужна.

К сожалению, с этим ничего не сделать. Картинка в начале статьи, конечно, прикольная, но я не ДиКаприо и понимаю, как оно на самом деле:

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

Джанго - это такой огромный слон, который идёт куда-то своей дорогой, и сколько ты не вякай, его это не волнует. Что-то изменить в нём - нереально, а разочарование от непринятого merge request больно ударит по самолюбию. Я не могу изменить Джанго, но могу изменить себя - поэтому я просто стараюсь лучше кодить и скептически смотреть на вещи.

А ещё можно написать статью! Ей-богу, я потратил, наверно, пару месяцев, чтобы просто написать всё это - каждый божий день я находил что-то, говорил про себя "окей, Джанго" и добавлял сюда - и я точно уверен, что после написания статьи у меня появится ещё немало пунктов. Ну и если у вас есть собственные "пункты" по Джанго, или кто-то в интернете (я) оказался не прав - пишите в комментах!

И кстати, если вам нравится, что я пишу, то приглашаю вас к себе в телеграм канал: во-первых, планируется парочка статей не под формат хабра, а во-вторых - и это его конкурентное преимущество - там не пишет Hetman software. Да, @deniskin так можно было с самого начала.

Автор:
kesn

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js