django

Нас Django вкус волнует и манит

  • пятница, 20 декабря 2019 г. в 00:32:58
https://habr.com/ru/post/481042/
  • Python
  • Django


Кушаем кактус

Прошло уже несколько недель, как официально вышла 3 версия Django. Я работал с этой версией ещё до публикации официального релиза и, к сожалению, заметил, что развитие Django сильно замедлилось. Версия 1.3 от 1.7 отличается в разы, а вот 3 версия содержит косметические изменения ветки 2 и не более.

Мой проект winePad стартовал с версии Django 1.3, и к текущему моменту в нем переопределено около 12% внутреннего кода Django.
Видя код новой версии, я понимаю, что правки, которые я или мои коллеги сделали при работе с предыдущими версиями поедут и дальше. А глядя на roadmap и вялотекущие изменения официального репозитория ждать, что ошибки будут скорректированы в будущих версиях — не приходится.
Вот о работе над ошибками я и хочу рассказать:

Метод get


Мало кто догадывается о том, что в стандартном методе get django с самого начала была ошибка. Метод get должен вернуть вам либо один объект либо предупредить о том что найдено несколько объектов или же сообщить, что объектов нет.
1  def get(self, *args, **kwargs):
2         clone = self._chain() if self.query.combinator else self.filter(*args, **kwargs)
3         if self.query.can_filter() and not self.query.distinct_fields:
4             clone = clone.order_by()
5         limit = None
6         if not clone.query.select_for_update or connections[clone.db].features.supports_select_for_update_with_limit:
7             limit = MAX_GET_RESULTS
8             clone.query.set_limits(high=limit)
9         num = len(clone)
10        if num == 1:
11            return clone._result_cache[0]
12        if not num:
13            raise self.model.DoesNotExist()
14        raise self.model.MultipleObjectsReturned()

Строка 9 получает данные по ВСЕМ записям которые указаны в queryset и переводит их в набор объектов. Об этом есть предупреждение в документации.
До 3 версии ограничения на количество запрашиваемых объектов не было. Это означает, что get получал абсолютно все данные и превращал их в объекты, прежде, чем дать предупреждение что объектов много.
В итоге вы могли получить несколько миллионов объектов в памяти, только для того, чтобы узнать, что найден более, чем 1 объект. Сейчас появились строки 5,7,8. И теперь вы гордо получите только MAX_GET_RESULTS=21 объект, прежде чем узнать, что объектов больше чем 1.
При тяжёлом "__init__" задержка будет значительной. Как лечить:
Переопределить MAX_GET_RESULTS в django.db.models.query.py
переопределить GET или перед вызовом GET использовать:
vars(queryset.query).update({'high_mark':2, 'low_mark':0})
или
queryset.query.set_limits(0,2)

Метод __init__ моделей Django


Не совсем понятно объявление в коде встроенного метода __init__
_setattr=setattr

вероятно, это для псевдоубыстрения кода переносом ссылки на функцию в локальный словарь, но речь не об этом. Проблем несколько:

1. Если передать дополнительные пары аттрибут=значение в __init__ модели вы получите «got an unexpected keyword argument».
В таком случае я предлагаю не утяжелять метод __init__ а делать добавление атрибутов после инициализации:
obj = MyClass()
vars(obj).update({'attr':val, 'attr2':val2 ...})


2. В новой Django добавили возможность переопределения дескрипторов на любое поле модели (Field.descriptor_class). Но ни один дескриптор не знает, инициализирован объект, или ещё нет. Это надо, например, если дескриптор будет использовать данные из prefetch_related объектов, которые появятся только после инициализации главного объекта.
Использовать сигнал окончания инициализации мне не нравится, поскольку подписчиков может быть очень много.
В таком случае я не придумал ничего умнее, чем переопределить __init__ и добавить аттрибут окончания инициализации.
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._end_init = True


Querysets.


Жестко прописанные EmptyQuerySet/DateQuerySet убраны, уже хорошо. Однако ситуация с queryset в роли менеджера мне идеологически не нравится.
Если я хочу переопределить класс QuerySet создаваемых менеджерами, я добавляю аттрибут _queryset_class
class MyManager(models.Manager):
    _queryset_class = MyQuerySet


Внимание, для старых версий это не работает, можно сделать, например, так:
class MyManager(models.Manager):
    def get_query_set(self):
        response = super(MyManager, self).get_query_set()
        response.__class__ = MyQuerySet
        return response


inlineFormset панели администратора.


Проблем несколько:
1. Нельзя отобразить стандартным inlineFormset записи не имеющие напрямую связь с главным объектом формы. Например: форма правки цен товара. В середине лежит справочная статистическая Tabularinline форма закупочных оптовых цен на «подобный» товар.

Решается переопределением метода get_formset inline модели и созданием собственного MyFormSet унаследованного от BaseInlineFormSet
def get_formset(self, request, obj=None, **kwargs):
        kwargs['formset'] = MyFormSet
        super().get_formset(request, obj, **kwargs)

class MyFormSet(BaseInlineFormSet):
    pass


2. Если вы правите обьект с inlineformset в админ панели, а в это время кто-то удалит одну из записей обьектов внутри inlineformset через другой механизм, то вы получите ошибку и сохранить обьект не удастся. Только через kopy paste в новом окне браузера.
Я нашел пока только одно Решение — не использовать inlineformset.

Панель администратора.


«Киллер-фича» Django, является самым большим кактусом проекта:

1. Действие «Удаление объектов» в администраторах моделей видны по умолчанию, не важно, есть ли права у пользователя на удаление, или нет.
Решается отключением этого действия по умолчанию:
admin.site.disable_action('delete_selected')


2. Создание дополнительных прав пользователя из админ панели будет невозможно пока вы не включите администратор модели Permissions:
from django.contrib.auth.models import Permission
class PermissionsAdmin(admin.ModelAdmin):
	search_fields = ('name', 'codename','content_type__app_label', 'content_type__model')
	list_display = ('name', 'codename',)
	actions = None
admin.site.register(Permission, PermissionsAdmin)  


3. Увы, прав на доступ только к определенным объектам в Django не существует.
Это возможно решить через прописывание записи в djangoAdminLog со специальным флагом.
А после проверять наличие флага:
user.logentry_set.filter(action_flag=ENABLED, .....).exists()


4. Если вы создаете действия администратора, так, как стоит в документации, то помните, что они не протоколируются в djangoAdminLog автоматически.

5. Еще недостаток этой части документации — все примеры только на функциях. А как же GCBV? В моих проектах все действия администраторов моделей переведены на GCBV. Репозиторий.
Подключение действия стандартно:
class MyAdmin(admin.ModelAdmin):
    actions = (MyActionBasedOnActionView.as_view(),)


ContentType — реестр моделей django.


50% гениальность / 50% тупость.
Ни у одной модели нет доступа к реестру моделей по умолчанию.
У нас в проектах решается добавлением миксина во все классы:
from django.contrib.contenttypes.models import ContentType
class ExportMixin(object):
    @classmethod
    def ct(cls):
        if not hasattr(cls, '_ct'):
            cls._ct, create = ContentType.objects.get_or_create(**cls.get_app_model_dict())
            if create:
                cls._ct.name = cls._ct.model._meta.verbose_name
                cls._ct.save()
        return cls._ct

    @classmethod
    def get_model_name(cls):
        if not hasattr(cls, '_model_name'):
            cls._model_name = cls.__name__.lower()
        return cls._model_name

    @classmethod
    def get_app_name(cls):
        if not hasattr(cls, '_app_name'):
            cls._app_name = cls._meta.app_label.lower()
        return cls._app_name

    @classmethod
    def get_app_model_dict(cls):
        if not hasattr(cls, '_format_kwargs'):
            cls._format_kwargs = {'app_label': cls.get_app_name(), 'model': cls.get_model_name()}
        return cls._format_kwargs

теперь мы можем вызывать obj.ct() при необходимости.

UserModel


Возможность переопределения модели пользователя появилась в версии 1.5.
Но к 3 версии так и не исправили model=User в стандартных UserCreationForm/UserChangeForm.
Решается:
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth import get_user_model
class MyUserCreationForm(UserCreationForm):
    class Meta:
        model= get_user_model()


Система переводов


Разметка текстов, видимых пользователю, выполняется тегами
{% trans %}
или в коде через
gettext_lazy

Однако, управление этими ресурсами в админпанели отсутствует. Вообще.

Есть внешние решения, все они работают кое-как.
Например, Rosetta систематически теряет тексты, и глючно работает интерфейс. Нигде нет проверки прав доступа к переводам. Для работы необходимы систематические makemessages / compilemessages…

В winePad теги trans, blocktrans и gettext_lazy переопределены и мы стали получать тексты из кеша. Если нет кеша, то кешированный запрос из базы get_or_create избавил нас и от makemessages.

Тема мультиязычности — вообще сложная. Встроенное в Django решение работает только для статических текстов. А ведь есть еще необходимость перевода данных моделей. Я попробовал по-своему решить вопрос перевода динамических текстов в проекте django-TOF, где я соединил возможности model-translation и Parler/Hvad. Вероятно, кому-то будет интересно заглянуть.

Пока я остановлю повествование, поскольку статья по исправлению недостатков Django легко может превратится в longread.

Прошу вас рассказать, как вы улучшаете свою Django. Если будет продолжение, я систематизирую появившиеся идеи.

p.s. Некоторые коды написаны в старой нотации, надеюсь на понимание, не всегда находится время или сотрудники на рефакторинг.