- 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 %}
Висновок
- Подібний підхід корисний не тільки для динамічних віджетів, але і в цілому може бути дуже хорошим архітектурним рішенням для ряду завдань.
- Перевага такого підходу полягає в тому, що ви можете значно спростити код шаблонів сайту, а також приховати реальну реалізацію за полиморфной моделлю, яка виступає в ролі адаптера.
- Також дуже важливим є те, що для впровадження подальших типів об'єкта досить буде лише додати нову модель реалізації і оновити всі необхідні переменнию в адміністративній моделі, в деяких випадках дописати форми для фронтенда.
- Але найголовніше те, що це дійсно є рішенням, яке формує архітектуру проекту. А архітектура означає те, що подальше розширення вже буде більш менш стандартизовано і при роботі декількох програмістів в проекті не потребують для новачків писати щось нове. Їм достатньо буде зрозуміти як функціонує дана архітектура, а впровадження нових типів стане для легкої рутинної завданням.