Blueprints: як я замінив сотні шаблонів словниками Python
Система blueprint, яка перетворює словники Python на повноцінні сторінки admin — таблиці, фільтри, форми, дії, дозволи та меню — без жодного HTML-шаблону. Як декоратор, TypedDict і JSON-контракт усунули цілий клас повторюваної роботи на фронтенді.
Проблема: 40 сторінок адмінки, один розробник
Я будував бек-офісну систему для торгової платформи. Вимоги були знайомі кожному, хто будував внутрішні інструменти: рахунки, баланси, ордери, комісії, звіти, дашборди ризиків - близько 40 окремих подань даних, кожне з фільтрацією, сортуванням, діями, інлайн-редагуванням і дозволами на основі ролей.
Традиційний підхід Django був би таким: написати шаблон для кожної сторінки, подання для кожної сторінки, URL-патерни для кожної сторінки, класи форм для кожної дії та JavaScript для кожної взаємодії. Помнож це на 40 - і ти отримуєш місяці повторюваної роботи та кодову базу, де зміна стовпця таблиці означає редагування HTML, JavaScript і Python у трьох різних файлах.
Я вже бачив, на що здатна адмінка Django з інтроспекцією моделей. Але це була не стандартна адмінка - це був кастомний бек-офіс зі складною бізнес-логікою, кастомними дозволами, масовими операціями та таблицями даних, керованими через API. ModelAdmin у Django був недостатньо гнучким. Мені було потрібно щось, що давало б ту саму силу "оголоси і відрендери", але для довільних подань, керованих через API.
Тож я побудував систему блюпринтів.
Основна ідея: сторінка — це просто словник
Фундаментальне усвідомлення полягає в тому, що кожна адмінсторінка дотримується одного й того самого шаблону: отримати дані з API, показати їх у таблиці зі стовпцями, дозволити користувачам фільтрувати й сортувати, надати дії над рядками та опційно дозволити редагування на місці. Якщо шаблон завжди однаковий, то єдине, що змінюється від сторінки до сторінки, — це конфігурація.
Blueprint — це словник Python, який описує все про сторінку: які дані отримувати, які стовпці показувати, які фільтри пропонувати, які дії доступні та хто має дозвіл щось бачити. Один узагальнений шаблон і один модуль JavaScript рендерять кожну сторінку - blueprint просто вказує їм, що рендерити.
from frontend.blueprints import register_blueprint
from frontend.types import BlueprintDict
from frontend.filters import Filter
from django.urls import reverse_lazy
@register_blueprint(
name="accounts",
parent_menu="Accounts Management",
menu_title="Account Management",
menu_order=1,
)
def accounts_bp(request) -> BlueprintDict:
return {
"page_title": "Accounts",
"data_url": reverse_lazy("account:accounts_v1"),
"headers": {
"ID": "id",
"Account Name": "acc_name",
"Currency": "currency.asset_name",
"Group": "group.name",
"Tariff Plan": "tariff_plan.name",
"Is Active": "is_active",
},
"formatters": {"Is Active": "ToBool"},
"filters": [
Filter.DROPDOWN("Currency", "currency__id", "assets-filter"),
Filter.DROPDOWN("Group", "group__id", "account-groups"),
Filter.BOOLEAN("Is Active", "is_active", default_value=True),
],
"actions": [
{
"title": "Edit",
"color": "warning",
"action": "openFormModal",
"form_blueprint": "account_edit",
"permission": "accounts_can_update",
}
],
"global_search": True,
}
Це повноцінна адмін-сторінка. Таблиця з шістьма стовпцями, три фільтри, кнопка редагування з контролем прав доступу, глобальний пошук та автоматична реєстрація в меню. Жодного файлу шаблону. Жодного URL-патерну для написання. Жодного JavaScript для налаштування. Декоратор бере на себе розміщення в меню та генерацію URL. Загальне представлення бере на себе рендеринг.
Реєстр Blueprint: декоратори, що будують навігацію
Декоратор @register_blueprint робить більше, ніж просто зберігає функцію в словнику. Він будує всю навігаційну структуру застосунку:
# Global blueprint registry
BLUEPRINTS: Dict[str, Callable[..., BlueprintDict]] = {}
def register_blueprint(
name: str,
parent_menu: Optional[str] = None,
menu_title: Optional[str] = None,
menu_order: int = 100,
menu_type: str = "section",
):
def decorator(func):
# 1. Register in global registry
BLUEPRINTS[name] = func
# 2. Auto-generate URL: "accounts" -> /int-data/accounts/
url_name = name.replace("_", "-")
url = reverse_lazy("int_view", args=[url_name])
# 3. Register in menu hierarchy
if parent_menu:
register_blueprint_in_menu(
blueprint_name=name,
group_id=parent_menu.lower().replace(" ", "_"),
title=menu_title or name.replace("_", " ").title(),
url=url,
order=menu_order,
)
return func
return decorator
Коли команда нового застосунку додає blueprint, меню бічної панелі оновлюється автоматично. Жодного ручного налаштування меню, жодного супроводу таблиці маршрутизації. Blueprint і є реєстрацією. Це той самий принцип, що й admin.site.register() у Django admin, але застосований до всієї навігаційної системи застосунку.
Типобезпека: TypedDict як жива документація
Одним із рішень, що окупилося одразу ж, було використання TypedDict для кожної структури блюпринта. Замість того, щоб передавати звичайні словники й сподіватися, що ключі правильні, кожен блюпринт відповідає типізованій схемі, яку розуміють IDE:
from typing import Dict, List, Optional, Union
from typing_extensions import NotRequired, TypedDict
class HeaderFieldDict(TypedDict):
field: str
permission: NotRequired[Optional[str]]
is_editable: NotRequired[Optional[bool]]
round: NotRequired[Optional[bool]]
formatter_args: NotRequired[Optional[tuple]]
class BlueprintDict(TypedDict):
page_title: str
data_url: str
headers: Dict[str, Union[str, HeaderFieldDict]]
formatters: NotRequired[Optional[Dict[str, str]]]
filters: NotRequired[Optional[List[FilterDict]]]
actions: NotRequired[Optional[List[ActionDict]]]
static_actions: NotRequired[Optional[Dict[str, StaticActionDict]]]
global_actions: NotRequired[Optional[Dict[str, GlobalActionDict]]]
allow_update: NotRequired[Optional[bool]]
global_search: NotRequired[Optional[bool]]
Визначення TypedDict виконують потрійну роль: вони є анотаціями типів для mypy, підказками автозаповнення для IDE та документацією для розробників. Коли хтось пише нову схему, редактор підказує йому кожен доступний параметр, його тип і чи є він обовʼязковим. Помилилися в написанні ключа? Перевірка типів упіймає це ще до завантаження сторінки.
Це неможливо зі звичайними словниками. І це також неможливо з конфігураційними файлами YAML або JSON - ви втрачаєте інтеграцію з IDE в ту мить, коли залишаєте Python. Зберігання схем як типізованих словників Python дає вам гнучкість конфігурації з безпекою коду.
Система фільтрів: композовні конструктори запитів
Фільтри - одна з найбільш втомливих частин будь-якої таблиці даних. Кожному фільтру потрібен UI-компонент, параметр запиту та обробка на бекенді. Помножте це на 20+ типів фільтрів, які ми підтримуємо, і на 40 сторінок, що їх використовують, і ви отримаєте комбінаторний вибух повторюваного коду.
Система фільтрів на основі blueprint вирішує це за допомогою класу Filter, який надає статичні методи для кожного типу фільтра:
from frontend.filters import Filter
"filters": [
# Dropdown - fetches options from API endpoint
Filter.DROPDOWN("Account", "account__id", "accounts"),
# Boolean - Yes/No selection with optional default
Filter.BOOLEAN("Is Active", "is_active", default_value=True),
# Number range - returns TWO filter configs (min + max)
*Filter.NUMBER_RANGE("Amount", "amount"),
# Date range - from/to pair
*Filter.DATE_FROM_TO("Created", "created_at"),
# Datetime range with apply button
Filter.DATETIME_RANGE("Time Range", "transaction_time"),
# Fixed date range - predefined periods
Filter.FIXED_DATE_RANGE("Period", "created_at"),
# Options: today, yesterday, this_week, last_7_days,
# last_30_days, this_month, last_month, this_year, last_year
# Static choices
Filter.CHOICE("Status", "status", [
{"id": "active", "name": "Active"},
{"id": "pending", "name": "Pending"},
]),
# Enum to choices conversion
Filter.CHOICE("Risk", "risk_status",
Filter.CHOICES_FROM_ENUM(RiskStatus)),
# Multi-select dropdown
Filter.MULTI_SELECT_DROPDOWN("Symbol", "symbol__in", "instruments"),
]
Кожен метод Filter повертає словник, який JavaScript-рендерер таблиці знає, як обробити. Розробник ніколи не пише HTML фільтрів, ніколи не підключає обробники подій, ніколи не будує рядки запитів. Він декларує, що хоче відфільтрувати, а система робить решту. Особливо приємним є оператор розпакування (*Filter.NUMBER_RANGE()) - один виклик розгортається у дві окремі конфігурації фільтрів для мінімуму та максимуму.
Form Blueprints: форми Django без класів форм
Blueprint таблиці відповідає за відображення даних. Але адмінсторінкам також потрібні форми - форми створення, редагування та масових дій. Традиційний підхід Django - написати клас forms.Form або ModelForm для кожної операції. З 40 сторінками й кількома діями на кожній це чимало класів форм.
Blueprint форм усувають їх повністю. Форма визначається як словник зі специфікаціями полів, а клас DynamicForm генерує форму Django під час виконання:
from frontend.forms.forms import InputsFields, InputWidgets
from frontend.forms.blueprints import FORM_BLUEPRINTS, FormBlueprint
ACCOUNT_CREATE_BLUEPRINT: FormBlueprint = {
"name": "account_create",
"title": "Create Account",
"description": "Create a new account",
"column_count": 2,
"modal_size": "xl",
"method": "POST",
"api_config": "account:accounts_v1",
"success_message": "Account created successfully",
"fields": [
{
"name": "acc_name",
"type": InputsFields.CHAR_FIELD,
"widget": InputWidgets.TEXT_INPUT,
"label": "Account Name",
"required": True,
"max_length": 255,
"row_class": "col-md-6",
},
{
"name": "currency",
"type": InputsFields.CHOICE_FIELD,
"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",
},
],
}
FORM_BLUEPRINTS["account_create"] = ACCOUNT_CREATE_BLUEPRINT
Клас DynamicForm зчитує схему (blueprint) і створює справжні поля форми Django з відповідними віджетами, валідацією та компонуванням. Він відображає InputsFields.CHAR_FIELD на forms.CharField, InputWidgets.TEXT_INPUT на forms.TextInput і обробляє випадні списки автозаповнення на основі API через Select2. Ключ permission у кожному полі визначає, чи може поточний користувач редагувати це поле - якщо у нього немає дозволу, поле відображається як доступне лише для читання, а не приховується.
Рушій DynamicForm: від схеми до Django-форми
Магія відбувається у класі DynamicForm, який розширює forms.Form Django й динамічно створює поля з конфігурації blueprint:
class DynamicForm(forms.Form):
FIELD_MAP = {
InputsFields.CHAR_FIELD: forms.CharField,
InputsFields.CHOICE_FIELD: forms.ChoiceField,
InputsFields.INTEGER_FIELD: forms.IntegerField,
InputsFields.FLOAT_FIELD: forms.FloatField,
InputsFields.BOOLEAN_FIELD: forms.BooleanField,
InputsFields.DATE_FIELD: forms.DateField,
# ... 16 field types total
}
WIDGET_MAP = {
InputWidgets.TEXT_INPUT: forms.TextInput,
InputWidgets.SELECT: forms.Select,
InputWidgets.SELECT2: Select2Widget,
InputWidgets.CHECKBOX_INPUT: forms.CheckboxInput,
# ... 15 widget types total
}
def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request", None)
self.submit_url = kwargs.pop("submit_url", None)
super().__init__(*args, **kwargs)
self.load_fields_from_config() # Blueprint -> Django fields
self._setup_crispy_form_helper() # Layout with Crispy Forms
def load_fields_from_config(self):
for field_config in self.scheme.get("fields", []):
field_class = self.FIELD_MAP[field_config["type"]]
widget_class = self.WIDGET_MAP.get(field_config.get("widget"))
# Build kwargs: required, max_length, label, help_text...
self.fields[field_config["name"]] = field_class(**kwargs)
Клас FormRenderer потім бере цю форму, застосовує обмеження полів на основі дозволів (роблячи поля доступними лише для читання для користувачів, у яких немає потрібного дозволу), будує URL для надсилання з api_config схеми та відображає все у модальне вікно Bootstrap через Crispy Forms. Увесь конвеєр - від словника до відрендереного модального вікна з надсиланням через AJAX - не потребує від розробника жодного коду шаблонів.
Потік запитів: одне подання обслуговує 40 сторінок
Уся система blueprint маршрутизується через єдине представлення Django. Кожна сторінка в бек-офісі звертається до того самого URL-патерну, тієї самої функції представлення та того самого шаблону:
# urls.py - One URL pattern for everything
urlpatterns = [
path("int-data/<str:view_blueprint_name>/",
views.int_view, name="int_view"),
]
# views.py - One view function for everything
@login_required
def int_view(request, view_blueprint_name) -> HttpResponse:
blueprint_key = view_blueprint_name.replace("-", "_")
# Look up the blueprint function
blueprint = BLUEPRINTS.get(blueprint_key)
if not blueprint:
raise PermissionDenied(f"Blueprint '{blueprint_key}' not found")
# Check section-level permissions
if not request.user.is_superuser:
user_access = get_user_section_access(request.user, blueprint_key)
if not user_access.get("can_read", False):
raise PermissionDenied
# Execute blueprint function and render
context = blueprint(request)
return render(request, "base_table_page.html", context)
URL /int-data/accounts/ викликає accounts_bp(request). URL /int-data/balances/ викликає balances_bp(request). Те саме представлення (view), той самий шаблон, різні дані. Шаблон отримує словник схеми як контекст, серіалізує його в теги JSON script, і рендерер таблиць на JavaScript бере справу у свої руки.
Права доступу на кожному рівні
Система схем застосовує дозволи на трьох рівнях, і всі вони оголошені в конфігурації схеми - а не в окремих перевірках дозволів, розкиданих по представленнях:
- Рівень розділу: Чи може користувач взагалі отримати доступ до цієї сторінки? Перевіряється в
int_viewще до того, як буде викликана функція схеми. - Рівень дії: Чи може користувач бачити кнопку Редагувати? Кнопку Затвердити? Кожна дія в схемі має ключ
permission. Дії фільтруються на боці клієнта на основі ролі користувача. - Рівень поля: Чи може користувач редагувати поле валюти? Ставку комісії? Кожне поле форми має необовʼязковий ключ
permission. Якщо у користувача його немає, поле відображається як доступне лише для читання з візуальним індикатором - не приховане, оскільки він усе одно повинен бачити дані.
Цей багаторівневий підхід означає, що єдине визначення схеми обробляє як інтерфейс, так і контроль доступу. Немає окремого файлу дозволів, який треба підтримувати, немає стеку декораторів на представленнях, немає умовних конструкцій у шаблонах, що перевіряють {% if perms.app.can_edit %}.
Доступ до вкладених полів і форматувальники
Дані з реального світу ніколи не бувають пласкими. У рахунку є валюта, яка має назву. У замовлення є трейдер, у якого є email. Система заголовків blueprint обробляє доступ до вкладених полів через крапкову нотацію:
"headers": {
"ID": "id", # Simple field
"Currency": "currency.asset_name", # Nested: obj.currency.asset_name
"Group": "group.name", # Nested: obj.group.name
"Risk Profile": "group.risk_profile.name", # Three levels deep
"Is Active": { # Advanced config
"field": "is_active",
"is_editable": True, # Inline editable
"permission": "accounts_can_update",
},
"Amount": {
"field": "amount",
"round": True, # Round numeric values
"formatter_args": (2,), # 2 decimal places
},
},
# Formatters transform raw values into display values
"formatters": {
"Is Active": "ToBool", # true/false -> checkmark/cross
"Account": "ToAccountLink", # ID -> clickable link
"Created At": "ToDateTime", # ISO string -> formatted date
}
Рендерер таблиць на JavaScript розвʼязує "currency.asset_name", проходячи вкладений обʼєкт з відповіді API. Форматери - це функції JavaScript, зареєстровані за іменем; схема лише оголошує, який форматер використовувати для якого стовпця. Додавання нового форматера - це одне визначення функції, і воно миттєво стає доступним для кожної схеми в системі.
Глобальні дії: масові операції з конфігурації
Одна з найпотужніших можливостей - це глобальні дії, тобто масові операції, що працюють над вибраними рядками. Користувач позначає кілька рахунків, натискає "Change Risk Status", заповнює модальну форму, і всі вибрані рахунки оновлюються одним викликом API.
Увесь цей процес налаштовується в blueprint:
"global_actions": {
"risk_status": {
"permission": "account_risk_update",
"title": "Change Risk Status",
"description": "Update risk status for selected accounts",
"form": serialize_blueprint_form("account_risk_status_bulk_action"),
"app": "account",
"url_name": reverse_lazy("account:bulk_updates_v1"),
"method": "PATCH",
},
"activate": {
"permission": "account_activate",
"title": "Activate / Deactivate",
"description": "Toggle active status",
"form": serialize_blueprint_form("account_is_active_bulk_action"),
"app": "account",
"url_name": reverse_lazy("account:bulk_updates_v1"),
"method": "PATCH",
},
}
Функція serialize_blueprint_form бере назву схеми форми, генерує форму Django, відображає її в HTML і серіалізує в контекст сторінки. Коли користувач натискає дію, JavaScript відкриває модальне вікно з попередньо відрендереною формою, збирає обрані ідентифікатори рядків і надсилає все через AJAX. Вісім масових дій на сторінці рахунків, кожна зі своєю формою, і єдиний код, специфічний для кожної дії, - це визначення її схеми.
Що усуває ця архітектура
Після побудови 40+ сторінок за допомогою blueprints, ось чого кодова база не містить:
- Нуль шаблонів для конкретних сторінок — кожна сторінка адмінки використовує
base_table_page.html - Нуль JavaScript для конкретних сторінок — рендерер таблиць, обробник фільтрів та менеджер дій є узагальненими
- Нуль ручних URL-патернів на сторінку — один URL-патерн із динамічним параметром обслуговує все
- Нуль файлів класів форм — форми генеруються з blueprints
- Нуль ручної реєстрації меню — декоратори будують бічну панель автоматично
Додавання нової сторінки адмінки — це один Python-файл із декорованою функцією, що повертає словник. Жодного нового шаблону, жодного нового URL, жодного нового JavaScript, жодної конфігурації меню. 41-ша сторінка потребує стільки ж зусиль, скільки й 2-га.
Родовід Django Admin
Ця система - прямий нащадок філософії Django admin. Django admin каже: "Дай мені модель, і я побудую UI." Blueprints кажуть: "Дай мені словник, і я побудую UI."
Різниця в тому, що Django admin тісно зв'язаний із моделями Django та ORM. Blueprints відв'язані від джерела даних - вони працюють з будь-яким API-ендпоінтом, будь-яким форматом даних, будь-яким бекендом. Ви можете спрямувати blueprint на REST API, GraphQL-ендпоінт чи статичний JSON-файл, і рендеринг був би ідентичним.
Але ключовий інсайт той самий: якщо ви опишете структуру своїх даних і свої патерни взаємодії, фреймворк може побудувати інтерфейс. Вам не потрібно вручну майструвати кожну таблицю, кожен фільтр, кожну форму, кожну перевірку дозволів. Вам потрібно описати, чого ви хочете, і дати системі це відрендерити.
Django admin довів, що це можливо, у 2005 році. Blueprints доводять, що це масштабується на складні, кастомні, керовані через API бек-офісні застосунки у 2025 році.
Blueprint — це не скорочення, а твердження, що сторінки admin — це дані, а не код. Щойно ви це приймаєте, шаблони, шаблони URL, класи форм і JavaScript усі стають проблемою фреймворка, а не вашою.