Architecture 2026-04-24·Ievgenii Svyryd

Blueprints: як я замінив сотні шаблонів словниками Python

Система blueprint, яка перетворює словники Python на повноцінні сторінки admin — таблиці, фільтри, форми, дії, дозволи та меню — без жодного HTML-шаблону. Як декоратор, TypedDict і JSON-контракт усунули цілий клас повторюваної роботи на фронтенді.

Blueprints: як я замінив сотні шаблонів словниками Python

Проблема: 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 усі стають проблемою фреймворка, а не вашою.

Зв'язатися →