Позбудьтеся жорстко закодованих форм: як генерація форм на основі JSON змінила підхід до розробки Django-застосунків
Що, якби кожна форма у вашому Django-застосунку - створення, редагування, масове оновлення, модальне вікно, inline - була визначена як словник Python замість класу? Дві продакшн-системи навчили мене, що форми - це дані, а не код, і ця різниця все змінює.
Біль від захардкоджених форм
Кожен розробник Django знає цей ритуал. Ви визначаєте модель. Потім пишете ModelForm. Потім підправляєте віджети. Потім додаєте crispy-розкладку. Потім клієнт каже "додайте поле", і ви торкаєтеся чотирьох файлів. Потім він каже "зробіть це поле умовним", і ви додаєте JavaScript. Потім він каже "та сама форма, але в модальному вікні", і ви дублюєте шаблон.
Помножте це на 20 моделей - і ви утримуєте цілу маленьку армію класів форм, кожен трохи інакший, кожен захардкоджений, кожен тихенько розсинхронізовується з моделлю, яку він представляє.
Я вперся в цю стіну на двох дуже різних проєктах - промисловій системі контролю якості виробництва та бек-офісі фінансового трейдингу - і обидва рази дійшов того самого рішення: перестаньте писати класи форм і почніть писати конфігурації форм.
Основна ідея: форми — це дані
Поле форми Django насправді є лише набором властивостей: ім'я, тип, віджет, мітка, чи є воно обов'язковим, які має варіанти вибору. У цьому описі немає нічого, що вимагало б визначення Python-класу. Це дані, а дані належать структурі даних.
Ідея проста: визначайте поля як словники, зберігайте їх у схемі й дозвольте одному узагальненому класу форми будувати себе з цієї схеми під час виконання.
# Instead of this:
class InspectionForm(forms.Form):
diameter = forms.FloatField(
label="Inner diameter",
widget=forms.NumberInput(attrs={"class": "form-control"}),
)
status = forms.ChoiceField(
choices=[("pass", "Pass"), ("fail", "Fail")],
widget=forms.Select(attrs={"class": "form-select"}),
)
# Write this:
INSPECTION_SCHEMA = {
"fields": [
{
"name": "diameter",
"type": "FloatField",
"widget": "NumberInput",
"label": "Inner diameter",
"widget_attrs": {"class": "form-control"},
},
{
"name": "status",
"type": "ChoiceField",
"widget": "Select",
"label": "Status",
"choices": [("pass", "Pass"), ("fail", "Fail")],
"widget_attrs": {"class": "form-select"},
},
]
}
Рушій DynamicForm
Уся система тримається на одному класі. Він відображає рядкові ідентифікатори на класи полів та класи віджетів Django, а потім ітерує схему, щоб побудувати себе:
class DynamicForm(forms.Form):
FIELD_MAP = {
"CharField": forms.CharField,
"ChoiceField": forms.ChoiceField,
"FloatField": forms.FloatField,
"IntegerField": forms.IntegerField,
"BooleanField": forms.BooleanField,
"DateField": forms.DateField,
"DecimalField": forms.DecimalField,
"EmailField": forms.EmailField,
# ... every Django field type
}
WIDGET_MAP = {
"TextInput": forms.TextInput,
"Select": forms.Select,
"NumberInput": forms.NumberInput,
"Textarea": forms.Textarea,
"CheckboxInput": forms.CheckboxInput,
"DateInput": forms.DateInput,
# ... every Django widget type
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_cfg in self.scheme["fields"]:
field_class = self.FIELD_MAP[field_cfg["type"]]
widget_class = self.WIDGET_MAP[field_cfg["widget"]]
self.fields[field_cfg["name"]] = field_class(
label=field_cfg["label"],
widget=widget_class(
attrs=field_cfg.get("widget_attrs", {})
),
required=field_cfg.get("required", True),
choices=field_cfg.get("choices", []),
)
Це весь рушій. Кожен тип поля Django, кожен тип віджета, зіставлений через пошук за рядком. Нова форма - це новий словник, а не новий клас.
Приклад 1: контроль якості на виробництві
Першим проєктом була продакшн-система даних для промислового виробника. Інспектори якості дотримуються Стандартних операційних процедур (SOP), які вимагають вимірювання конкретних розмірів, перевірки обробки поверхонь та фіксації результатів пройдено/не пройдено для кожного етапу виробництва.
Виклик: понад 20 різних інспекційних форм, кожна з унікальними полями, специфічними для типу заготовки та кроку SOP. Інспекція торцевої пластини на вертикальному токарному верстаті вимірює інші речі, ніж інспекція фрезерування біполярної пластини.
З жорстко закодованими формами - це понад 20 класів форм, кожен зі своїми визначеннями полів, своєю валідацією, своїми примхами шаблону. З підходом blueprint - це понад 20 словників і один клас форми.
# Each QC inspection is just a schema definition
QC_ENDPLATE_LATHE_SCHEMA = {
"fields": [
{
"name": "inner_diameter",
"widget": "TextInput",
"label": _("Inner diameter: 1846mm"),
},
{
"name": "outer_diameter",
"widget": "TextInput",
"label": _("Outer diameter: 1876-1876.1mm"),
},
{
"name": "surface_qualified",
"type": "ChoiceField",
"widget": "Select",
"label": _("Surface qualification"),
"choices": [
("qualified", _("Qualified")),
("unqualified", _("Unqualified")),
],
},
]
}
# The form class is one line
class EndPlateLathQCForm(DynamicForm, QCValidationMixin):
scheme = QC_ENDPLATE_LATHE_SCHEMA
Зібрані дані зберігаються у вигляді JSON у полі JSONField, зберігаючи як мітки полів, так і значення. Це означає, що запис інспекції є самоописовим - ви можете прочитати його, не знаючи, яка схема його згенерувала.
# Data stored as self-describing JSON
{
"inner_diameter": {
"label": "Inner diameter: 1846mm",
"value": "1846.02"
},
"surface_qualified": {
"label": "Surface qualification",
"value": "qualified"
}
}
Приклад 2: бек-офіс фінансового трейдингу
Другий проєкт розвинув патерн далі. Бек-офіс для торгівлі криптовалютою потребував форм для рахунків, комісій, профілів ризику, статусу відповідності, тарифних планів - кожна з різними полями, різними дозволами, різними методами подання (POST для створення, PATCH для масових оновлень) та різними контекстами рендерингу (повна сторінка, модальне вікно, вбудований режим).
Тут blueprint виріс за межі визначень полів у повну специфікацію форми:
ACCOUNT_CREATE_BLUEPRINT: FormBlueprint = {
"name": "account_create",
"title": "Create Account",
"column_count": 2,
"modal_size": "xl",
"method": "POST",
"api_config": "account:accounts_v1",
"success_message": "Account created successfully",
"fields": [
{
"name": "currency",
"type": "ChoiceField",
"label": "Currency",
"permission": "ACCOUNT_CURRENCY_UPDATE",
"api_choices": "common:unified_search",
"api_choices_args": ["assets-v1"],
"api_choices_autocomplete": True,
"row_class": "col-md-6",
},
{
"name": "commission_rate",
"type": "FloatField",
"label": "Commission Rate (%)",
"permission": "ACCOUNT_COMMISSION_UPDATE",
"widget_attrs": {"step": "0.01", "min": "0"},
"row_class": "col-md-6",
},
],
}
Зверніть увагу, що закодовано в цьому єдиному словнику: HTTP-метод, кінцева точка API, розмір модального вікна, які поля потребують яких дозволів, звідки завантажувати варіанти для випадного списку, сітка макета Bootstrap і повідомлення про успіх. Це цілий досвід роботи з формою в чистих даних.
Видимість полів на основі прав доступу
Фінансова платформа додала потужну функцію: контроль дозволів на рівні окремого поля. Кожне поле в blueprint може оголосити рядок дозволу. Рендерер форми перевіряє дозволи поточного користувача та деградує коректно:
class FormRenderer:
def _apply_field_permissions(self, fields, user):
user_perms = get_user_section_permissions(user)
for field in fields:
required_perm = field.get("permission")
if required_perm and required_perm not in user_perms:
field.setdefault("widget_attrs", {})
field["widget_attrs"]["disabled"] = True
field["widget_attrs"]["readonly"] = True
Користувачі без дозволу ACCOUNT_COMMISSION_UPDATE бачать поле ставки комісії, але воно доступне лише для читання й затемнене. Жодного окремого шаблону, жодних блоків {% if perms.x %}, жодної логіки умовного рендерингу. Blueprint оголошує правило, а рендерер його забезпечує.
Варіанти вибору, завантажені через API, та автодоповнення
Статичні списки варіантів підходять для випадних меню на кшталт "Кваліфікований/Некваліфікований", але селектор валюти має тягнути дані з живого API. Blueprint обробляє це за допомогою двох властивостей:
api_choices- ім'я URL Django, яке повертає варіантиapi_choices_autocomplete- вмикає Select2 з пошуком на боці сервера
Рушій форм розв'язує ім'я URL, отримує варіанти під час рендерингу та підключає автодоповнення Select2, якщо його налаштовано. Автор blueprint ніколи не пише JavaScript - він просто декларує, де знаходяться дані.
Чому це краще за жорстко закодовані форми
Після побудови двох великих застосунків із цим патерном я можу точно сформулювати, що він вам дає:
- Додавання форми — це додавання даних, а не коду. Нова QC-інспекція або новий тип облікового запису — це новий словник. Жодного нового класу, жодного нового шаблону, жодного нового URL. Система виявляє й рендерить її.
- Зміна форми — це зміна в одному місці. Неправильна мітка поля? Змініть словник. Потрібен новий варіант випадаючого списку? Оновіть список варіантів. Потрібно зробити поле необов'язковим? Встановіть
"required": False. Один файл, одна зміна, готово. - Форми стають переносними. Blueprint — це просто словник. Ви можете серіалізувати його в JSON, зберегти в базі даних, завантажити з конфігураційного файлу або надіслати через API. Рушію форм байдуже, звідки взялася схема.
- Узгодженість є автоматичною. Кожна форма проходить через один і той самий конвеєр рендерингу. Атрибути віджетів, CSS-класи, поведінка компонування — усе стандартизується рушієм, а не копіюється між класами форм.
- Самоописове зберігання даних. Коли дані форми зберігаються як JSON із мітками та значеннями, збережений запис є придатним для читання людиною без звернення до схеми. Звіти та експорти стають тривіальними.
- Валідаційні міксини чисто компонуються. Динамічна форма — це все ще Django-форма. Ви можете додавати міксини для валідації, специфічної для домену (перевірки існування заготовки, валідація дозволів), не торкаючись схеми.
Коли варто вдатися до цього патерну
Динамічні форми не завжди є правильним вибором. Для форми входу з двома полями ModelForm простіший і зрозуміліший. Але цей патерн сяє, коли:
- У вас багато схожих форм із різними наборами полів (QC-інспекції, кроки онбордингу, сторінки опитувань)
- Форми мають бути конфігурованими без змін коду - адміністраторами, клієнтами чи зовнішніми системами
- Вам потрібне керування полями на основі дозволів для багатьох форм без дублювання шаблонної логіки
- Дані форм потрібно зберігати як самоописовий JSON, а не у фіксованих колонках бази даних
- Ви будуєте платформу з кількома тенантами, кожному з яких потрібні дещо різні форми
Початкова ціна - це один клас DynamicForm (близько 50 рядків) та дисципліна визначати схеми замість класів форм. Винагородою є система, де додавання 21-ї форми потребує тих самих зусиль, що й додавання 2-ї.
Жорстко закодована форма — це знімок сьогоднішніх вимог. Схема форми — це контракт, який еволюціонує. Напишіть контракт.