Как стать автором
Обновить

Откровения про отсутствующий Nested Inline от разработчика с очень маленьким Django

Python *Django *
разработчик с маленьким Django


— Стыдно признаться, но в нашей компании мы до сих пор используем Django…

Так начинали со мной разговор представители навороченных стендов российских конференций Pycon Russia 2021 и Moscow Python Conf++ 2021, где я выступал с докладами про Django.

Эдакий "coming out" без объяснений, почему это стыдно, и зачем в этом надо признаваться. Если уж «Все леди делают это» так давайте говорить об этом, как о чем-то нормальном! Я, например, рассказываю, как делать это в удовольствие и с естественными извращениями. Я про работу с Django, конечно, а вы, о чем подумали?

Под естественными извращениями я подразумеваю решение задач при помощи Django без дополнительных батареек. Этот фреймворк имеет все, чтобы решать сложные задачи просто, но, как я уже писал ранее, отсутствие информации о необходимом существующем функционале Django не дает этого сделать. WTF?

Что начинает делать разработчик Django-проекта, когда кажется, что необходимых инструментов нет? Качать тоннами ненужные батарейки с https://djangopackages.org/ или начинает рассказывать о том, как Django может привести к ментальным заболеваниям.

Во время вышеупомянутых конференций, на докладе про выбор подходящего python-фреймворка Django Admin упрекнули в отсутствии Nested-Inlines (inline в inline). Мол, больше десятилетия лежит Issue на djangoproject и разработчики не могут это поправить.

А что если Nested-Inlines в Django Admin был возможен с начальных версий? Подтвердить или опровергнуть мои слова смогут дочитавшие эту статью до конца. Остальным остается смириться: понимание работы Django с полным отсутствием нормальной документации требует особой любви к извращениям.

И, вот теперь, слайды:

Предположим, у Вас проект e-commerсe. В проекте есть модель Shop, каждый объект этой модели содержит объекты модели Product, которые, в свою очередь, могут иметь связь с несколькими объектами модели Images.

from django.db import models
from django.utils.translation import gettext_lazy as _

class Shop(models.Model):
    class Meta:
        verbose_name = _('Это модель магазина')

    title = models.CharField(verbose_name=_('это название магазина'), max_length=255)

class Product(models.Model):
    class Meta:
        verbose_name = _('Это модель продукта в магазине')

    title = models.CharField(verbose_name=_('это название продукта'), max_length=255)
    shop = models.ForeignKey(Shop, verbose_name=_('это ссылка на магазин'), on_delete=models.CASCADE)

class Image(models.Model):
    class Meta:
        verbose_name = _('Это картинка продукта')

    src = models.ImageField(verbose_name=_('это файл картинки'))
    product = models.ForeignKey(Product, verbose_name=_('это ссылка на продукт'), on_delete=models.CASCADE)

Давайте создадим администраторы моделей, для управления данными.

from django.contrib.admin.options import ModelAdmin

class ImageModelAdmin(ModelAdmin):
    fields = 'title',

class ProductModelAdmin(ModelAdmin):
    fields = 'title',

class ShopModelAdmin(ModelAdmin):
    fields = 'title',

Знающий Django разработчик на этом месте, скорее всего, воскликнет:

— Администраторы моделей не зарегистрированы!

И, скорее всего, это потому, что он просто пропустил информацию про авторегистрацию ModelAdmin, которая, как суслик, вроде его нет, а он есть.

Сразу сделаем через Model-Inline правку продуктов непосредственно на странице правки магазина, и добавление картинок на странице правки продукта.

from django.contrib.admin.options import ModelAdmin, TabularInline, StackedInline
from django.utils.translation import gettext_lazy as _
from .models import Image, Product

class ImageAdminInline(TabularInline):
    extra = 1
    model = Image

class ProductModelAdmin(ModelAdmin):
    inlines = ImageAdminInline,
    fields = 'title',

class ProductInline(StackedInline):
    extra = 1
    model = Product

class ShopModelAdmin(ModelAdmin):
    inlines = ProductInline,
    fields = 'title',

Если Вы вовремя остановились в своем проекте на этом этапе, то Вы точно имеете минимум одну проблему: ошибка сохранения объекта из формы правки, если объект одновременно правят несколько пользователей.

В примере выше — ошибка будет появляться при работе нескольких пользователей в ShopModelAdmin с одним и тем же объектом модели Shop.

Допустим, Вы решили эту проблему батарейкой для версионирования, поскольку о версионировании из Django-коробки вам никто не рассказал. Заодно Вы прикрутили сторонний модуль раздачи прав для доступа к отдельным объектам моделей, поскольку, вы и предположить не могли, что это тоже возможно в Django.

Все? Работа завершена? А не, показалось…
Завершенный Django-проект? Да не, бред какой-то

Задача размещения Любого Inline в любом месте формы правки объекта.


Ваш любимый менеджер приходит и спрашивает:
— А можно на форме правки продукта сначала показать Inline картинок, а только потом название продукта?
— Да, можно.

По — умолчанию в Django в форме правки объекта модели в Панели администраторов сначала стоят все поля редактируемого объекта, а потом идет блок инлайнов. Задачу вставки одного inline «куда хочется» на форму решается разными путями.
Первое сносное решение я встретил в 2015 году. Там было про переопределение шаблона формы правки 'admin/change_form.html' примерно так.

Почти «съедобное» решение я встретил позже в 2018 году у какого-то чеха, он делал вставку inline через дополнительные поля AdminForm с переопределением шаблона. Это выглядело проще и универсальнее, но еще не тянуло на «простое» решение на Django. Увы, без proof link.
А можно ли разместить любой Inline в любом месте формы администратора модели только силами Django с минимальным количеством кода? Возможно! Но это не точно…

В Django можно все. Но это не точно.

Вероятно, Вам знакома парадигма добавленного поля в ModelAdminForm. Вы объявляете несуществующее в модели поле в полях AdminModelForm, отмечаете его только для чтения. В момент рендера формы результат вызова одноименного метода ModelAdmin будет отображен на форме. Важно, что последовательность рендера ModelAdmin такова, что сначала создаются inline, потом рендерится форма, и, потом, рендерится блок inline. Этим мы и воспользуемся:

from django.contrib.admin.options import ModelAdmin, TabularInline
from django.utils.translation import gettext_lazy as _
from django.template.loader import get_template
from .models import Image

class ImageAdminInline(TabularInline):
    extra = 1
    model = Image

class ProductModelAdmin(ModelAdmin):
    inlines = ImageAdminInline,
    fields = 'image_inline', 'title',
    readonly_fields= 'image_inline',

    def image_inline(self, *args, **kwargs):
        context = getattr(self.response, 'context_data', None) or {} # somtimes context.copy() is better
        inline = context['inline_admin_formset'] = context['inline_admin_formsets'].pop(0)
        return get_template(inline.opts.template).render(context, self.request)

    def render_change_form(self, request, *args, **kwargs):
        self.request = request
        self.response = super().render_change_form(request, *args, **kwargs)
        return self.response

В момент рендера change_form будет вызван метод image_inline, который вынет из списка еще не обработанных inline_admin_formsets один inline_formset, и отрендерит его там, где хочется. Ниже change_form отрендерятся оставшиеся inline_admin_formsets, если они есть у ModelAdmin.

all inlines after admin change form vs inline into admin change form

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

Знающий Django разработчик на этом месте воскликнет:

— Нельзя использовать self в методах ModelAdmin как контейнер для хранения данных!
Замечание принято. Действительно, в Django без доработок этот код приведет к появлению ошибок.
Кто не в курсе – для панелей администраторов Django хранит в реестре проинициализированные объекты классов ModelAdmin, работа всех пользователей обслуживается только этими объектами. Использовать эти объекты как контейнеры для пользовательских данных не получится. Тут Django полностью противоречит своей парадигме GCBV. Как это достаточно просто исправить, объяснено мною тут.
Все. Работа завершена. А, не, показалось…

Задача размещения Nested-Inline в формe правки объекта.


Все тот же ваш любимый менеджер приходит и спрашивает:
-А можно на форме добавления продуктов в магазин сразу показать Inline для картинок?
— Да. Можно.

Вы, конечно же, любите своих менеджеров и бежите закачивать Django-Nested-Inline. По пути Вы отмечаетесь на djangoproject.com в issue на добавление nested-inline. Вас вовсе не останавливает мысль о том, что проблему синхронной правки товаров в магазине никто не отменял, и теперь она начнет проявляться в геометрической прогрессии:

$\sum_{0}^{Nuser}err = N_{inlines}*N_{nested-inlines}*N_{managers}$

Эта угроза всегда раньше обходила вас стороной, только потому, что у вас не было Nested-Inline. Исправим это упущение.
Идея Nested-Inline базируется на предыдущей идее о размещении Inline в любом месте формы. Просто продолжим эту мысль дальше: Добавленное поле вставляем внутрь Admin inline, а в такое поле, как мы уже делали, вставляем еще inline, и получим автоматически какую-то херню Inline-в-Inline.

Давайте же сделаем это:

Создадим добавленное поле в инлайне продукта.

class ProductInline(StackedInline):
    model = Product
    fields = 'title', 'image_inline'
    readonly_fields = 'image_inline',
    extra = 1

    def image_inline(self, obj=None, *args, **kwargs):
        context = getattr(self.modeladmin.response, 'context_data', None) or {}
        admin_view = ProductModelAdmin(self.model, self.modeladmin.admin_site).add_view(self.modeladmin.request)
        inline = admin_view.context_data['inline_admin_formsets'][0]
        return get_template(inline.opts.template).render(context | {'inline_admin_formset': inline}, self.modeladmin.request)

В добавленное поле объекта ProductInline я вставил ImageInline, взятый из ProductModelAdmin.
Разумеется, это не конечный вариант, например, для существующего объекта надо вызывать change_view, и передавать ему object_id. Предлагаю вам подумать об этом на досуге.
Далее вызовем ShopModelAdmin с исправленным ProductInline. Заодно добавим необходимые данные для рендера nested inline (и не только) в добавленном поле image_inline.

class ShopModelAdmin(ModelAdmin):
    inlines = ProductInline,
    fields = 'title',

     def render_change_form(self, request, *args, **kwargs):
        self.request = request
        response = self.response = super().render_change_form(request, *args, **kwargs)
        return response

    def get_inline_instances(self, *args, **kwargs):
        yield from ((inline, vars(inline).update(modeladmin=self))[0] for inline in super().get_inline_instances(*args, **kwargs))

Запускаем. Как я и сообщал, получилась хрень.

кривой nested-inline в Django

Django-Core разработчики может и хороши в Python, но вот c Javascript функциями на старом jquery в панелях администраторов творится какая-то лютая дичь. Глубоко по-читаемый мной kesn отмечал, что:
нужно всего-то пожениться на джанговском javascript и всё там переписать.
Не, не надо. Это будет мезальянс. Просто поверьте мне на слово, что в файле inlines.js надо поменять всего три строки:

//  django\contrib\admin\static\admin\js\inlines.js
// row 335:
$(selector).stackedFormset(selector, inlineOptions.options);
// change to 
$(this).find("[id^=" + inlineOptions.name.substring(1) + "-]").stackedFormset(selector, inlineOptions.options);

// rows 338-339:
selector = inlineOptions.name + "-group .tabular.inline-related tbody:first > tr.form-row";
$(selector).tabularFormset(selector, inlineOptions.options);
// change to 
selector = inlineOptions.name + "-group .tabular.inline-related tbody:first > tr[id^=" + inlineOptions.name.substring(1) + "-].form-row ";
$(this).children().tabularFormset(selector, inlineOptions.options);

Увы, ребята в djangoproject ошибку inline.js не признают. Если Вы все сделали правильно, получите вот такую красоту:

рабочая версия Nested-Inline

Можете усложнить задачу и сделать больше уровней вложений. Рекомендую для глубоких вложенностей использовать TabularInline, а то StackedInline после второго вложения грустно выглядит.

Все. Работа завершена. А, не, показалось…

Задача сохранения данных


С одной стороны, я показал, как получить работающий масштабируемый рендер Inline-в-Inline.
С другой…. А вы не думали, как это все будет сохраняться?

Созданная нами только что проблема сохранения состоит из нескольких частей.

  • Для работы всей этой пидерсии на нужны правильные префиксы форм для вложенных инлайнов. Это просто решаемая задача.
  • ModelAdmin валидирует данные для главного объекта и для объектов первой вложенности. Валидацию для вложенных инлайнов надо делать отдельно. Это тоже решаемая задача, но чуть сложнее.
  • Обычное сохранение в панели администраторов подразумевает сохранение главного объекта и объектов первой вложенности. Вызов сохранения информации вложенных инлайнов надо делать отдельно. Это продолжение предыдущей задачи.
  • А вот как быть с тем, что множественная правка несколькими пользователями одновременно множества разнородных объектов приводит к множественным конфликтам состояний объектов? Это оставлю вам на размышление. Вы же Nested-inline хотели? Теперь мучайтесь.

Передача префикса


Выполняется через переопределение функции __init__ формы инлайна, где мы будем передавать prefix формы как префикс для вложенного формсета.

class MyForm(StackedInline.form):
    def __init__(self, *args, **kwargs):
        super(MyForm, self).__init__(*args, **kwargs)
        self.instance.form = self

class ProductInline(StackedInline):
    ......
    form = MyForm

    def image_inline(self, obj=None, *args, **kwargs):
        ......
        inline.formset.prefix = f'{inline.formset.prefix}-{obj.form.prefix}'
        return get_template(inline.opts.template).render(self.context, self.context['request'])

Чуть позже палки в колеса с префиксами опять начнет вставлять родной javascript. Пока, вам придется поверить на слово, что для первой работоспособной версии и так сойдет.

Валидация конечных данных


С валидацией придется повозиться. Вешается, она на ту же форму MyForm. Но есть большое НО! Если вызвать, метод add_view c request.POST — то данные этого вдоженного инлайна сохранятся, даже если вы этого не хотите. В Django modelAdmin вообще невозможно проверить, что данные валидны. Я сообщил об этом в очередном djangoproject issue, и был в очередной раз послан не понят. И, кстати, на этом моменте я понял, что формирование вложенного inline проще делать в MyForm:

class MyForm(StackedInline.form):
    ....
    def is_valid(self):
        return super().is_valid() and self.nested.formset.is_valid()

    @cached_property
    def nested(self):
        modeladmin = ProductModelAdmin(self._meta.model, self.modeladmin.admin_site)
        formsets, instances = modeladmin._create_formsets(self.modeladmin.request, self.instance, change=self.instance.pk)
        inline = modeladmin.get_inline_formsets(self.modeladmin.request, formsets[:1], instances[:1], self.instance)[0]
        inline.formset.prefix = f'{self.prefix}_{formsets[0].prefix}'.replace('-', '_')
        return inline

class ProductInline(StackedInline):
    ....
    def image_inline(self, obj=None, *args, **kwargs):
        context = getattr(self.modeladmin.response, 'context_data', None) or {}
        return get_template(obj.form.nested.opts.template).render(context | {'inline_admin_formset': obj.form.nested}, self.modeladmin.request)

    def get_formset(self, *args, **kwargs):
        formset = super().get_formset(*args, **kwargs)
        formset.form.modeladmin = self.modeladmin
        return formset


Сохранение вложенностей


image

Автосохранение вложенных инлайнов сделаем через переопределение метода save формы MyForm

class MyForm(StackedInline.form):
    def save(self, *args, **kwargs):
        return super().save(*args, **kwargs) or self.nested.formset.save(*args, **kwargs)

Вроде сохранилось:

image

Краткая сводка моих действий:


  1. Создал приложение с тремя связанными классами и администраторы этих моделей с Inline.
  2. Встроил добавленное поле в ChangeForm для вставки туда Inline.
  3. Встроил добавленное поле в Inline.
  4. На примере вставки Inline в добавленное поле ChangeForm, я вставил в добавленное поле Inline еще один inline, полученный из администратора другой модели.
  5. Решил вопрос генерации префиксов форм из inline, вложенных в inline
  6. Решил вопрос валидации данных форм из inline, вложенных в inline
  7. Сохранил данные форм из inline, вложенных в inline
  8. Как смог, поправил inlines.js

Что не решено:

  1. inlines.js не верно создает кнопки удаления для tabularinline форм, если extra != 0
  2. inlines.js криво работает с префиксами вложенных инлайнов, у новосозданных родительских инлайнов.
  3. inlines.js не учитывает extra для вложенных inline у новосозданных родительских инлайнов.
  4. Не обработан случай, если метод nested формы MyForm вернет что-то другое.

Упомянутые в статье возможности Django


  • Авторегистрация ModelAdmin
  • Версионирование состояний объектов Django-моделей
  • Управление доступом к объектам Django-моделей

Итоговое решение заняло около 20 строк кода на Django 4.0, масштабируется и не использует сторонние библиотеки. Смотрите в репозитории, пробуйте. Буду рад, если подскажете, что я упустил.

Выводы


Я хотел на этом примере показать, что решить нетривиальную для Django-разработчика задачу можно с минимальным количеством усилий используя только возможности самого фреймворка.
Получилось или нет? Решать вам.

P.S. Большой дисклеймер о том, что все персонажи из статьи являются вымышленными, и любое совпадение с реально живущими или жившими людьми не случайно.

P.P.S. Предложенная идея работает в моих проектах уже долгое время, я благодарю моего коллегу Павла П., который является тестером всех моих сумасшедших идей и участвовал в доработке этой.


«Django — это наше все» лозунг команды разработчиков, где я уже 7 лет занимаюсь обслуживанием и обновлением Django проектов. Если в Вашем Django-проекте есть нестандартные или сложные задачи — давайте попробуем решить их вместе.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Какие у вас мысли от прочтения этой статьи:
42% автор знает толк в извращениях! 21
12% автор, да что ты знаешь про извращения! 6
16% Cпасибо, пофапал 8
30% Не понял. Обещали про изврат, а тут что-то про программирование. 15
Проголосовали 50 пользователей. Воздержались 15 пользователей.
Теги:
Хабы:
Всего голосов 14: ↑11 и ↓3 +8
Просмотры 4.8K
Комментарии Комментарии 24

Работа

Data Scientist
143 вакансии
Python разработчик
155 вакансий