- 1. Постановка задачі
- 2. models
- 3. admin.py
- 4. Шаблони
- 5. Шаблоновий теги
- 6. Висновок
Днями вирішував завдання додавання на сайті динамічних віджетів з можливістю додавання різних видів віджетів, а також з можливістю подальшого необмеженого розширення набору віджетів за рахунок додавання моделей специфічних реалізацій.
Система така, що існує один клас Widget , який в залежності від обраного при створенні типу звертається до моделі специфічної реалізації, наприклад, HTMLWidgetImpl , яка містить якісь спеціальні параметри, а також знає про своє шаблоні для рендеринга. Widget в шаблоні сайту при виклику методу 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 форми в залежності від вибранног типу віджета. Як видно з коду, я вибираю форму за допомогою заздалегідь підготовленого словника 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]
Реєстрація в адміністративній моделі
Залишається тільки зареєструвати моделі в адміністративній панелі. І зауважте, я не реєструю окремо специфічні реалізації. Я вважаю, що подібна інформація не повинна бути доступна безпосередньо, а тільки через Inline форми.
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>
Шаблоновий теги
І на закінчення покажу, як відобразити SideBar за допомогою шаблонного тега
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
{% load sidebar from evileg_widgets %} {% sidebar %}
Висновок
- Подібний підхід корисний не тільки для динамічних віджетів, але і в цілому може бути дуже хорошим архітектурним рішенням для ряду завдань.
- Перевага такого підходу полягає в тому, що ви можете значно спростити код шаблонів сайту, а також приховати реальну реалізацію за полиморфной моделлю, яка виступає в ролі адаптера.
- Також дуже важливим є те, що для впровадження подальших типів об'єкта досить буде лише додати нову модель реалізації і оновити всі необхідні переменнию в адміністративній моделі, в деяких випадках дописати форми для фронтенда.
- Але найголовніше те, що це дійсно є рішенням, яке формує архітектуру проекту. А архітектура означає те, що подальше розширення вже буде більш менш стандартизовано і при роботі декількох програмістів в проекті не потребують для новачків писати щось нове. Їм достатньо буде зрозуміти як функціонує дана архітектура, а впровадження нових типів стане для легкої рутинної завданням.