- 1. Постановка задачи
- 2. models
- 3. admin.py
- 4. Шаблоны
- 5. Теги шаблона
- 6. Вывод
На днях решил проблему добавления динамических виджетов на сайт с возможностью добавления разных типов виджетов, а так же с возможностью дальнейшего неограниченного расширения набора виджетов путем добавления моделей конкретных реализаций.
Система такова, что есть один класс Widget , который в зависимости от выбранного при его создании типа относится к конкретной модели реализации, например HTMLWidgetImpl , который содержит некоторые специальные параметры, а также знает о его шаблоне для рендеринга. Виджет в шаблоне сайта при вызове метода render() относится к конкретной реализации и его методу render()** через тип.
Такой подход позволяет написать предельно простой код для шаблона, а также организовать код таким образом, что добавление новых виджетов будет заключаться в добавлении новой модели данных, а также правке ряда мест в коде, а точнее их обновлении . Что по сути станет рутинной задачей и снимет с программиста необходимость придумывать что-то новое.
То есть в этой статье я хочу показать не только решение какой-то задачи на уровне программного кода, но и показать подход к планированию архитектуры при решении подобных задач.
Постановка задачи
Итак на сайте нужно реализовать систему динамических виджетов, которые будут отображаться в сайдбаре сайта. В то же время мы хотим начать с двух типов виджетов, один будет содержать только html-код для рендеринга, а второй будет содержать заголовок и html-код для контента, который будет рендериться в предопределенный шаблон. При этом для отрисовки в сайдбаре нам нужно иметь возможность задавать последовательность виджетов.
Поэтому создаем новое приложение
python manage.py startapp evileg_widgets
А потом создадим все нужные нам модели, а нам нужны следующие модели
- SideBar - модель сайдбара, кстати, я буду оформлять ее как модель Singleton
- Widget - полиморфная модель виджета
- HTMLWidgetImpl - конкретная реализация виджета для вставки простого HTML-кода
- StandardWidgetImpl — конкретная реализация виджета для вставки кода рендеринга
- SideBarToWidget - модель для полей ManyToMany между моделью SideBar и моделью Widget
models
Начнем с моделей, которые мы создаем.
SideBar
Для модели SideBar я использую SingletonModel из стороннего приложения django-solo, которое предназначено для формирования объектов настройки, которые должны существовать в одном единственном экземпляре.
Модель SideBar содержит поле widgets, которое будет отвечать за набор подключаемых к сайту виджетов, но отношение ManyToMany будет формироваться через промежуточную модель SideBarToWidget, которая добавит порядок виджетов в SideBar.
# -*- coding: utf-8 -*- from django.db import models from django.template.loader import render_to_string from django.utils.translation import ugettext, ugettext_lazy as _ from solo.models import SingletonModel class SideBar(SingletonModel): class Meta: verbose_name = _('Sidebar') verbose_name_plural = _('Sidebars') widgets = models.ManyToManyField('Widget', through='SideBarToWidget') def __str__(self): return ugettext('Sidebar')
SideBarToWidget
По сути, мы явно задаем эту модель ради единственного поля
number
, которое позволяет нам устанавливать порядок виджетов.
Если вы не укажете эту модель явно, будет создана таблица, содержащая внешние ключи к объектам отношения ManyToMany.
class SideBarToWidget(models.Model): sidebar = models.ForeignKey(SideBar, verbose_name=_('Sidebar'), on_delete=models.CASCADE) widget = models.ForeignKey(Widget, verbose_name=_('Widget'), on_delete=models.CASCADE) number = models.PositiveIntegerField(verbose_name=_('Ordering')) def __str__(self): return self.widget.__str__() class Meta: ordering = ('number',)
Widget
А теперь самая интересная модель с полиморфным характером. Эта модель отличается тем, что определяет свое поведение выбранным при создании типом.
Тип виджета определяется полем
widget_type
.
widget_type = models.IntegerField( verbose_name=_('Widget type'), choices=WIDGET_TYPE_CHOICES, default=HTML )
Выбор типа поля настраивается с использованием следующих переменных класса
HTML = 1 STANDARD = 2 WIDGET_TYPE_CHOICES = ( (HTML, _('HTML Widget')), (STANDARD, _('Standard Widget')), ) WIDGET_TYPE_TO_IMPL = { HTML: 'htmlwidgetimpl', STANDARD: 'standardwidgetimpl' }
Благодаря этому типу я четко определяю, к какой таблице мне следует обратиться, чтобы получить конкретную реализацию.
При этом я придерживаюсь определенных правил формирования имени отношения.
Это уже можно правильно проследить в этой переменной.
WIDGET_TYPE_TO_IMPL = { HTML: 'htmlwidgetimpl', STANDARD: 'standardwidgetimpl' }
В этом словаре тип виджета соответствует имени свойства, отвечающего за доступ к конкретной реализации
- htmlwidgetimpl -> HTMLWidgetImpl
- standardwidgetimpl -> StandardWidgetImpl
Описание виджета, то есть метода str формируется следующим образом
def __str__(self): return self.description or str(dict(self.WIDGET_TYPE_CHOICES)[self.widget_type])
Как видите, я использую либо заданное описание для виджета, которое добавлено для удобства, либо описание типа виджета, которое я беру из переменной класса WIDGET_TYPE_CHOICES через текущее значение widget_type
Но самым интересным является метод render() , отвечающий за отрисовку виджета.
def render(self): impl = getattr(self, self.WIDGET_TYPE_TO_IMPL[self.widget_type]) return impl.render() if impl else ''
Метод рендеринга уже использует метапрограммирование, потому что с помощью текущего типа виджета я обращаюсь к конкретной реализации по имени свойства.
Для этого используется функция Python
getattr
. И если конкретная реализация существует, мало ли, что-то пошло не так, то я вызываю метод
render()
конкретной реализации.
Полный код
class Widget(models.Model): HTML = 1 STANDARD = 2 WIDGET_TYPE_CHOICES = ( (HTML, _('HTML Widget')), (STANDARD, _('Standard Widget')), ) WIDGET_TYPE_TO_IMPL = { HTML: 'htmlwidgetimpl', STANDARD: 'standardwidgetimpl' } class Meta: verbose_name = _('Widget') verbose_name_plural = _('Widgets') widget_type = models.IntegerField( verbose_name=_('Widget type'), choices=WIDGET_TYPE_CHOICES, default=HTML ) description = models.CharField(verbose_name=_('Description'), max_length=255, null=True, blank=True) def __str__(self): return self.description or str(dict(self.WIDGET_TYPE_CHOICES)[self.widget_type]) def render(self): impl = getattr(self, self.WIDGET_TYPE_TO_IMPL[self.widget_type]) return impl.render() if impl else ''
AbstractWidgetImpl
Чтобы обобщить некоторые части кода, я использую абстрактную модель для конкретных реализаций.
class AbstractWidgetImpl(models.Model): class Meta: abstract = True parent = models.OneToOneField(Widget, verbose_name=_('Widget'), on_delete=models.CASCADE, related_name='%(class)s', related_query_name='%(class)s')
С помощью этой модели я формирую отношение OneToOne конкретной реализации к виджету.
Самое интересное здесь следующие две вещи
- related_name='%(class)s'
- related_query_name='%(class)s'
Вообще-то таких статей в нескольких статьях я видел мало, но дело в том, что когда я пишу модель, не являющуюся абстрактной, запись %(class)s преобразует имя модели по следующему принципу
- HTMLWidgetImpl -> htmlwidgetimpl
Таким образом, я определяю правила формирования имен для свойств конкретных реализаций.
HTMLWidgetImpl
И вот мы наконец добрались до конкретных реализаций. Например, для HTMLWidgetImpl есть поле content , а также HTMLWidgetImpl знает о своем шаблоне для отрисовки evileg_widgets/htmlwidgetimpl.html .
Поэтому здесь у нас есть собственная реализация метода render() .
class HTMLWidgetImpl(AbstractWidgetImpl): class Meta: verbose_name = _('HTML Widget') verbose_name_plural = _('HTML Widgets') content = models.TextField(verbose_name=_('HTML')) def render(self): return render_to_string( template_name='evileg_widgets/htmlwidgetimpl.html', context={'content': self.content} )
StandardWidgetImpl
Точно так же мы реализовали конкретную реализацию для StandardWidgetImpl .
class StandardWidgetImpl(AbstractWidgetImpl): class Meta: verbose_name = _('Standard Widget') verbose_name_plural = _('Standard Widgets') title = models.CharField(verbose_name=_('Title'), max_length=255) content = models.TextField(verbose_name=_('HTML')) def render(self): return render_to_string( template_name='evileg_widgets/standardwidgetimpl.html', context={ 'title': self.title, 'content': self.content } )
admin.py
Теперь давайте посмотрим на панель администрирования и как она настраивается. Здесь код уже будет немного проще, поэтому приведу сразу, а потом опишу, что он делает.
# -*- coding: utf-8 -*- from django.contrib import admin from django.utils.translation import ugettext_lazy as _ from solo.admin import SingletonModelAdmin from evileg_widgets.models import ( SideBar, Widget, SideBarToWidget, HTMLWidgetImpl, StandardWidgetImpl ) class AbstractWidgetImplInline(admin.StackedInline): verbose_name_plural = _('Configuration') can_delete = False class HTMLWidgetImplInline(AbstractWidgetImplInline): model = HTMLWidgetImpl class StandardWidgetImplInline(AbstractWidgetImplInline): model = StandardWidgetImpl WIDGET_TYPE_TO_INLINE = { Widget.HTML: HTMLWidgetImplInline, Widget.STANDARD: StandardWidgetImplInline } class WidgetAdmin(admin.ModelAdmin): fields = ['widget_type', 'description', 'preview'] readonly_fields = ['preview'] def preview(self, obj): return obj.render() preview.short_description = _('Preview') def get_fields(self, request, obj=None): if obj is not None: return super().get_fields(request, obj) return ['widget_type'] def get_readonly_fields(self, request, obj=None): readonly_fields = super().get_readonly_fields(request, obj) if obj is not None: readonly_fields.append('widget_type') return readonly_fields def get_inline_instances(self, request, obj=None): if obj is not None: return [WIDGET_TYPE_TO_INLINE[obj.widget_type](self.model, self.admin_site)] return [] class SideBarToWidgetInline(admin.TabularInline): model = SideBarToWidget fields = ['number', 'widget'] verbose_name_plural = _('Widgets') extra = 1 class SideBarAdmin(SingletonModelAdmin): inlines = [SideBarToWidgetInline] admin.site.register(SideBar, SideBarAdmin) admin.site.register(Widget, WidgetAdmin)
Inline
Сначала я создаю Inline-формы для каждой конкретной реализации, а также привязываю их к типам виджетов.
class AbstractWidgetImplInline(admin.StackedInline): verbose_name_plural = _('Configuration') can_delete = False class HTMLWidgetImplInline(AbstractWidgetImplInline): model = HTMLWidgetImpl class StandardWidgetImplInline(AbstractWidgetImplInline): model = StandardWidgetImpl WIDGET_TYPE_TO_INLINE = { Widget.HTML: HTMLWidgetImplInline, Widget.STANDARD: StandardWidgetImplInline }
WidgetAdmin
После чего формирую администрирование формы виджета, в котором определяю два этапа отображения формы виджета.
При создании виджета разрешаю задавать только его тип
А то, в зависимости от типа, уже разрешаю редактирование конкретных свойств. Кстати, также отображается предварительный просмотр этого виджета. Для превью нужно добавить свой css, но к статье это не относится. А так же на скриншоте отображается мультиязычность, но это тоже не относится к статье, скажу только, что для мультиязычности нужно использовать django-modeltranslation
Выбор и настройка отображаемых полей
Выбор отображаемых полей в зависимости от того, был ли создан виджет, осуществляется путем переопределения метода get_fields
def get_fields(self, request, obj=None): if obj is not None: return super().get_fields(request, obj) return ['widget_type']
То же самое касается readonly_fields . Я специально ограничиваю возможность редактирования поля widget_type , когда объект виджета уже существует в базе данных. В противном случае это может усложнить логику программного кода администрирования виджетов. Я не хочу этого.
def get_readonly_fields(self, request, obj=None): readonly_fields = super().get_readonly_fields(request, obj) if obj is not None: readonly_fields.append('widget_type') return readonly_fields
Настройка встроенной формы
Но самым важным является выбор формы Inline в зависимости от выбранного типа виджета. Как видно из кода, я выбираю форму с помощью заранее подготовленного словаря Inline-объектов, соответствующих нужному типу конкретной реализации.
def get_inline_instances(self, request, obj=None): if obj is not None: return [WIDGET_TYPE_TO_INLINE[obj.widget_type](self.model, self.admin_site)] return []
SideBar
Ну и самое простое это отображение моделей Sidebar
class SideBarToWidgetInline(admin.TabularInline): model = SideBarToWidget fields = ['number', 'widget'] verbose_name_plural = _('Widgets') extra = 1 class SideBarAdmin(SingletonModelAdmin): inlines = [SideBarToWidgetInline]
Регистрация в административной модели
Остается только зарегистрировать модели в административной панели. И заметьте, я отдельно не регистрирую конкретные реализации. Я считаю, что такая информация не должна быть доступна напрямую, а только через встроенные формы.
admin.site.register(SideBar, SideBarAdmin) admin.site.register(Widget, WidgetAdmin)
Шаблоны
Так будут выглядеть в моем случае шаблоны конкретных реализаций
htmlwidgetimpl.html
{{ content|safe }}
standardwidgetimpl.html
<div class="card box-shadow m-2"> <h5 class="card-header text-center">{{ title }}</h5> <div class="card-body"> {{ content|safe }} </div> </div>
Теги шаблона
В заключение я покажу вам, как отображать боковую панель с помощью тега шаблона.
templatetags/evileg_widgets.py
# -*- coding: utf-8 -*- from django import template from evileg_widgets.models import SideBar register = template.Library() @register.inclusion_tag('evileg_widgets/sidebar.html') def sidebar(): return {'widgets': SideBar.get_solo().widgets.order_by('sidebartowidget__number')}
evilge_widgets/sidebar.html
{% for widget in widgets %} {{ widget.render }} {% endfor %}
Sidebar rendering
{% load sidebar from evileg_widgets %} {% sidebar %}
Вывод
- Такой подход полезен не только для динамических виджетов, но в целом может быть очень хорошим архитектурным решением для ряда задач.
- Преимущество такого подхода в том, что можно значительно упростить код шаблонов сайтов, а также скрыть реальную реализацию за полиморфной моделью, выполняющей роль адаптера.
- Так же очень важно, что для внедрения дальнейших типов объектов будет достаточно только добавить новую модель реализации и обновить все необходимые переменные в административной модели, в некоторых случаях добавить формы для фронтенда.
- Но самое главное, что это действительно решение, которое формирует архитектуру проекта. А архитектура означает, что дальнейшее расширение уже будет более-менее стандартизировано, и когда над проектом работает несколько программистов, не потребуется от новичков писать что-то новое. Им будет достаточно понять, как работает эта архитектура, и введение новых типов станет для них легкой рутиной.