Evgenii Legotckoi
09 травня 2020 р. 16:09

Django - Урок 054. Як створити полиморфную систему динамічних віджетів

Днями вирішував завдання додавання на сайті динамічних віджетів з можливістю додавання різних видів віджетів, а також з можливістю подальшого необмеженого розширення набору віджетів за рахунок додавання моделей специфічних реалізацій.

Система така, що існує один клас Widget , який в залежності від обраного при створенні типу звертається до моделі специфічної реалізації, наприклад, HTMLWidgetImpl , яка містить якісь спеціальні параметри, а також знає про своє шаблоні для рендеринга. Widget в шаблоні сайту при виклику методу render() звертається через тип до специфічної реалізації і вже її методу render() .

Подібний підхід дозволяє написати вкрай простий код для шаблону, а також організувати код так, що додавання нових віджетів буде полягає до в додаванні нової моделі даних, а також виправлення ряду місць в коді, вірніше їх поновлення. Яке по суті стане рутинною завданням і зніме з програміста необхідність придумувати щось нове.

Тобто в цій статті я хочу показати не тільки рішення якоїсь задачі на рівні програмного коду, а й показати підхід до планування архітектури при вирішенні подібних завдань.


Постановка задачі

Отже на сайті потрібно впровадити систему динамічних віджетів, які будуть відображатися в бічній панелі сайту. При цьому ми хочемо почати з двох видів віджетів, один буде містити просто html код для рендеринга, а другий буде містити заголовок і html код контенту, які будуть рендери в заздалегідь заданий шаблон. При цьому для рендеринга в бокой панелі нам необхідно вміти ставити послідовність віджетів.

Тому створюємо новий додаток

  1. 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.

  1. # -*- coding: utf-8 -*-
  2.  
  3. from django.db import models
  4. from django.template.loader import render_to_string
  5. from django.utils.translation import ugettext, ugettext_lazy as _
  6. from solo.models import SingletonModel
  7.  
  8.  
  9. class SideBar(SingletonModel):
  10. class Meta:
  11. verbose_name = _('Sidebar')
  12. verbose_name_plural = _('Sidebars')
  13.  
  14. widgets = models.ManyToManyField('Widget', through='SideBarToWidget')
  15.  
  16. def __str__(self):
  17. return ugettext('Sidebar')

SideBarToWidget

По суті дану модель ми задаємо явно заради одного поля number , яке дозволить задати порядок віджетів.
Якщо не ставити цю модель явно, то буде сформована таблиця яка містить зовнішні ключі на об'єкти ManyToMany відносин.

  1. class SideBarToWidget(models.Model):
  2. sidebar = models.ForeignKey(SideBar, verbose_name=_('Sidebar'), on_delete=models.CASCADE)
  3. widget = models.ForeignKey(Widget, verbose_name=_('Widget'), on_delete=models.CASCADE)
  4.  
  5. number = models.PositiveIntegerField(verbose_name=_('Ordering'))
  6.  
  7. def __str__(self):
  8. return self.widget.__str__()
  9.  
  10. class Meta:
  11. ordering = ('number',)

Widget

А ось тепер найцікавіша модель з поліморфним характером. Дана модель відрізняється тим, що визначає свою поведінку обраним при створенні типом.
Тип віджета визначається полем widget_type

  1. widget_type = models.IntegerField(
  2. verbose_name=_('Widget type'),
  3. choices=WIDGET_TYPE_CHOICES,
  4. default=HTML
  5. )

Вибори типу поля конфигурируются за допомогою наступних класових змінних

  1. HTML = 1
  2. STANDARD = 2
  3. WIDGET_TYPE_CHOICES = (
  4. (HTML, _('HTML Widget')),
  5. (STANDARD, _('Standard Widget')),
  6. )
  7.  
  8. WIDGET_TYPE_TO_IMPL = {
  9. HTML: 'htmlwidgetimpl',
  10. STANDARD: 'standardwidgetimpl'
  11. }

Завдяки цьому типу я чітко визначаю, до якої таблиці мені слід звернутися, щоб отримати специфічний реалізацію.
При цьому я дотримуюся певним правилам формування імені відносини.
Це правильно простежується вже в цієї змінної

  1. WIDGET_TYPE_TO_IMPL = {
  2. HTML: 'htmlwidgetimpl',
  3. STANDARD: 'standardwidgetimpl'
  4. }

В даному словнику тип віджета відповідає імені властивості, яке відповідає за доступ до специфічної реалізації

  • htmlwidgetimpl -> HTMLWidgetImpl
  • standardwidgetimpl -> StandardWidgetImpl

Опис віджету, тобто метод str формується так

  1. def __str__(self):
  2. return self.description or str(dict(self.WIDGET_TYPE_CHOICES)[self.widget_type])

Як бачите я використовую або заданий опис для віджета, яке додано для зручності, або опис типу віджета, яке забираю з класової змінної WIDGET_TYPE_CHOICES через поточне значення widget_type

Але найцікавішим буде метод render() , який відповідає за рендеринг віджета

  1. def render(self):
  2. impl = getattr(self, self.WIDGET_TYPE_TO_IMPL[self.widget_type])
  3. return impl.render() if impl else ''

У методі рендеринга вже використовується мета-програмування, оскільки за допомогою поточного типу віджета я звертаюся до специфічної реалізації на ім'я властивості.
Для цього використовується вбудована в python функція getattr . І якщо специфічна реалізація існує, хіба мало, щось пішло не так, то викликаю метод render() специфічної реалізації.

Повний код

  1. class Widget(models.Model):
  2. HTML = 1
  3. STANDARD = 2
  4. WIDGET_TYPE_CHOICES = (
  5. (HTML, _('HTML Widget')),
  6. (STANDARD, _('Standard Widget')),
  7. )
  8.  
  9. WIDGET_TYPE_TO_IMPL = {
  10. HTML: 'htmlwidgetimpl',
  11. STANDARD: 'standardwidgetimpl'
  12. }
  13.  
  14. class Meta:
  15. verbose_name = _('Widget')
  16. verbose_name_plural = _('Widgets')
  17.  
  18. widget_type = models.IntegerField(
  19. verbose_name=_('Widget type'),
  20. choices=WIDGET_TYPE_CHOICES,
  21. default=HTML
  22. )
  23.  
  24. description = models.CharField(verbose_name=_('Description'), max_length=255, null=True, blank=True)
  25.  
  26. def __str__(self):
  27. return self.description or str(dict(self.WIDGET_TYPE_CHOICES)[self.widget_type])
  28.  
  29. def render(self):
  30. impl = getattr(self, self.WIDGET_TYPE_TO_IMPL[self.widget_type])
  31. return impl.render() if impl else ''

AbstractWidgetImpl

Для узагальнення деяких частин коду я використовую абстрактну модель для специфічних реалізацій.

  1. class AbstractWidgetImpl(models.Model):
  2. class Meta:
  3. abstract = True
  4.  
  5. parent = models.OneToOneField(Widget, verbose_name=_('Widget'), on_delete=models.CASCADE, related_name='%(class)s',
  6. 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()

  1. class HTMLWidgetImpl(AbstractWidgetImpl):
  2. class Meta:
  3. verbose_name = _('HTML Widget')
  4. verbose_name_plural = _('HTML Widgets')
  5.  
  6. content = models.TextField(verbose_name=_('HTML'))
  7.  
  8. def render(self):
  9. return render_to_string(
  10. template_name='evileg_widgets/htmlwidgetimpl.html',
  11. context={'content': self.content}
  12. )

StandardWidgetImpl

Аналогічним чином у нас реалізована специфічна реалізація для StandardWidgetImpl

  1. class StandardWidgetImpl(AbstractWidgetImpl):
  2. class Meta:
  3. verbose_name = _('Standard Widget')
  4. verbose_name_plural = _('Standard Widgets')
  5.  
  6. title = models.CharField(verbose_name=_('Title'), max_length=255)
  7. content = models.TextField(verbose_name=_('HTML'))
  8.  
  9. def render(self):
  10. return render_to_string(
  11. template_name='evileg_widgets/standardwidgetimpl.html',
  12. context={
  13. 'title': self.title,
  14. 'content': self.content
  15. }
  16. )

admin.py

А тепер подивимося на панель адміністрування і то, як вона налаштовується. Тут код буде вже трохи простіше, тому приведу його відразу, а потім буду описувати, що він робить.

  1. # -*- coding: utf-8 -*-
  2.  
  3. from django.contrib import admin
  4. from django.utils.translation import ugettext_lazy as _
  5. from solo.admin import SingletonModelAdmin
  6.  
  7. from evileg_widgets.models import (
  8. SideBar,
  9. Widget,
  10. SideBarToWidget,
  11. HTMLWidgetImpl,
  12. StandardWidgetImpl
  13. )
  14.  
  15.  
  16. class AbstractWidgetImplInline(admin.StackedInline):
  17. verbose_name_plural = _('Configuration')
  18. can_delete = False
  19.  
  20.  
  21. class HTMLWidgetImplInline(AbstractWidgetImplInline):
  22. model = HTMLWidgetImpl
  23.  
  24.  
  25. class StandardWidgetImplInline(AbstractWidgetImplInline):
  26. model = StandardWidgetImpl
  27.  
  28.  
  29. WIDGET_TYPE_TO_INLINE = {
  30. Widget.HTML: HTMLWidgetImplInline,
  31. Widget.STANDARD: StandardWidgetImplInline
  32. }
  33.  
  34.  
  35. class WidgetAdmin(admin.ModelAdmin):
  36. fields = ['widget_type', 'description', 'preview']
  37. readonly_fields = ['preview']
  38.  
  39. def preview(self, obj):
  40. return obj.render()
  41.  
  42. preview.short_description = _('Preview')
  43.  
  44. def get_fields(self, request, obj=None):
  45. if obj is not None:
  46. return super().get_fields(request, obj)
  47. return ['widget_type']
  48.  
  49. def get_readonly_fields(self, request, obj=None):
  50. readonly_fields = super().get_readonly_fields(request, obj)
  51. if obj is not None:
  52. readonly_fields.append('widget_type')
  53. return readonly_fields
  54.  
  55. def get_inline_instances(self, request, obj=None):
  56. if obj is not None:
  57. return [WIDGET_TYPE_TO_INLINE[obj.widget_type](self.model, self.admin_site)]
  58. return []
  59.  
  60.  
  61. class SideBarToWidgetInline(admin.TabularInline):
  62. model = SideBarToWidget
  63. fields = ['number', 'widget']
  64. verbose_name_plural = _('Widgets')
  65. extra = 1
  66.  
  67.  
  68. class SideBarAdmin(SingletonModelAdmin):
  69. inlines = [SideBarToWidgetInline]
  70.  
  71.  
  72. admin.site.register(SideBar, SideBarAdmin)
  73. admin.site.register(Widget, WidgetAdmin)
  74.  

Inline

По-перше я формую Inline формсети для кожної специфічної реалізації, а також прив'язую їх до типів віджетів

  1. class AbstractWidgetImplInline(admin.StackedInline):
  2. verbose_name_plural = _('Configuration')
  3. can_delete = False
  4.  
  5.  
  6. class HTMLWidgetImplInline(AbstractWidgetImplInline):
  7. model = HTMLWidgetImpl
  8.  
  9.  
  10. class StandardWidgetImplInline(AbstractWidgetImplInline):
  11. model = StandardWidgetImpl
  12.  
  13.  
  14. WIDGET_TYPE_TO_INLINE = {
  15. Widget.HTML: HTMLWidgetImplInline,
  16. Widget.STANDARD: StandardWidgetImplInline
  17. }
  18.  

WidgetAdmin

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

При створенні віджету я дозволяю встановити тільки його тип

А потім в залежності від типу вже дозволяю редагувати конкретні властивості. До слова ще й відображаючи попередній цього віджета. Для попереднього перегляду вам знадобитися додати ваші css, але це до статті не відноситься. А також на скріншоті відображається і багатомовність, але це теж не отоносітся до статті, скажу тільки, що для багатомовності слід використовувати django-modeltranslation

Вибір і настройка видимі поля

Вибір контактів для відображення полів залежно від того, чи був створений віджет проводиться за допомогою перевизначення методу get_fields

  1. def get_fields(self, request, obj=None):
  2. if obj is not None:
  3. return super().get_fields(request, obj)
  4. return ['widget_type']

Теж саме відноситься і до readonly_fields . Я спеціально обмежую можливість редагування поля widget_type , коли об'єкт віджета вже існує в базі даних. В іншому випадку це може привести до ускладнення логіки програмного коду для адміністрування віджетів. А цього я не хочу.

  1. def get_readonly_fields(self, request, obj=None):
  2. readonly_fields = super().get_readonly_fields(request, obj)
  3. if obj is not None:
  4. readonly_fields.append('widget_type')
  5. return readonly_fields

Налаштування Inline форми

Але найважливішим є вибір Inline форми в залежності від вибранног типу віджета. Як видно з коду, я вибираю форму за допомогою заздалегідь підготовленого словника Inline об'єктів, які відповідають потрібного типу специфічної реалізації.

  1. def get_inline_instances(self, request, obj=None):
  2. if obj is not None:
  3. return [WIDGET_TYPE_TO_INLINE[obj.widget_type](self.model, self.admin_site)]
  4. return []

SideBar

Ну і найпростішим є відображення SideBar моделі

  1. class SideBarToWidgetInline(admin.TabularInline):
  2. model = SideBarToWidget
  3. fields = ['number', 'widget']
  4. verbose_name_plural = _('Widgets')
  5. extra = 1
  6.  
  7.  
  8. class SideBarAdmin(SingletonModelAdmin):
  9. inlines = [SideBarToWidgetInline]

Реєстрація в адміністративній моделі

Залишається тільки зареєструвати моделі в адміністративній панелі. І зауважте, я не реєструю окремо специфічні реалізації. Я вважаю, що подібна інформація не повинна бути доступна безпосередньо, а тільки через Inline форми.

  1. admin.site.register(SideBar, SideBarAdmin)
  2. admin.site.register(Widget, WidgetAdmin)

Шаблони

Так виглядатимуть шаблони специфічних реалізацій в моєму випадку

htmlwidgetimpl.html

  1. {{ content|safe }}

standardwidgetimpl.html

  1. <div class="card box-shadow m-2">
  2. <h5 class="card-header text-center">{{ title }}</h5>
  3. <div class="card-body">
  4. {{ content|safe }}
  5. </div>
  6. </div>

Шаблоновий теги

І на закінчення покажу, як відобразити SideBar за допомогою шаблонного тега

templatetags/evileg_widgets.py

  1. # -*- coding: utf-8 -*-
  2.  
  3. from django import template
  4. from evileg_widgets.models import SideBar
  5.  
  6. register = template.Library()
  7.  
  8.  
  9. @register.inclusion_tag('evileg_widgets/sidebar.html')
  10. def sidebar():
  11. return {'widgets': SideBar.get_solo().widgets.order_by('sidebartowidget__number')}
  12.  

evilge_widgets/sidebar.html

  1. {% for widget in widgets %}
  2. {{ widget.render }}
  3. {% endfor %}

Рендеринг SideBar

  1. {% load sidebar from evileg_widgets %}
  2. {% sidebar %}

Висновок

  • Подібний підхід корисний не тільки для динамічних віджетів, але і в цілому може бути дуже хорошим архітектурним рішенням для ряду завдань.
  • Перевага такого підходу полягає в тому, що ви можете значно спростити код шаблонів сайту, а також приховати реальну реалізацію за полиморфной моделлю, яка виступає в ролі адаптера.
  • Також дуже важливим є те, що для впровадження подальших типів об'єкта досить буде лише додати нову модель реалізації і оновити всі необхідні переменнию в адміністративній моделі, в деяких випадках дописати форми для фронтенда.
  • Але найголовніше те, що це дійсно є рішенням, яке формує архітектуру проекту. А архітектура означає те, що подальше розширення вже буде більш менш стандартизовано і при роботі декількох програмістів в проекті не потребують для новачків писати щось нове. Їм достатньо буде зрозуміти як функціонує дана архітектура, а впровадження нових типів стане для легкої рутинної завданням.

Вам це подобається? Поділіться в соціальних мережах!

Коментарі

Only authorized users can post comments.
Please, Log in or Sign up
  • Останні коментарі
  • Evgenii Legotckoi
    16 квітня 2025 р. 17:08
    Благодарю за отзыв. И вам желаю всяческих успехов!
  • IscanderChe
    12 квітня 2025 р. 17:12
    Добрый день. Спасибо Вам за этот проект и отдельно за ответы на форуме, которые мне очень помогли в некоммерческих пет-проектах. Профессиональным программистом я так и не стал, но узнал мно…
  • AK
    01 квітня 2025 р. 11:41
    Добрый день. В данный момент работаю над проектом, где необходимо выводить звук из программы в определенное аудиоустройство (колонки, наушники, виртуальный кабель и т.д). Пишу на Qt5.12.12 поско…
  • Evgenii Legotckoi
    09 березня 2025 р. 21:02
    К сожалению, я этого подсказать не могу, поскольку у меня нет необходимости в обходе блокировок и т.д. Поэтому я и не задавался решением этой проблемы. Ну выглядит так, что вам действитель…
  • VP
    09 березня 2025 р. 16:14
    Здравствуйте! Я устанавливал Qt6 из исходников а также Qt Creator по отдельности. Все компоненты, связанные с разработкой для Android, установлены. Кроме одного... Когда пытаюсь скомпилиров…