Blueprints: jak zastąpiłem setki szablonów słownikami Pythona
System blueprintów, który zamienia słowniki Pythona w kompletne strony admina - tabele, filtry, formularze, akcje, uprawnienia i menu - bez żadnych szablonów HTML. Jak dekorator, TypedDict i kontrakt JSON wyeliminowały całą klasę powtarzalnej pracy frontendowej.
Problem: 40 stron panelu administracyjnego, jeden programista
Budowałem system back-office dla platformy tradingowej. Wymagania były znajome każdemu, kto tworzył narzędzia wewnętrzne: konta, salda, zlecenia, prowizje, raporty, pulpity ryzyka - około 40 odrębnych widoków danych, każdy z filtrowaniem, sortowaniem, akcjami, edycją inline i uprawnieniami opartymi na rolach.
Tradycyjne podejście Django wyglądałoby tak: napisz szablon dla każdej strony, widok dla każdej strony, wzorce URL dla każdej strony, klasy formularzy dla każdej akcji oraz JavaScript dla każdej interakcji. Pomnóż to przez 40, a masz przed sobą miesiące powtarzalnej pracy i bazę kodu, w której zmiana kolumny tabeli oznacza edycję HTML-a, JavaScriptu i Pythona w trzech różnych plikach.
Widziałem już, co panel administracyjny Django potrafi zrobić z introspekcją modeli. Ale to nie był standardowy panel administracyjny - to był niestandardowy back-office ze złożoną logiką biznesową, niestandardowymi uprawnieniami, operacjami masowymi i tabelami danych zasilanymi przez API. ModelAdmin z panelu Django nie był wystarczająco elastyczny. Potrzebowałem czegoś, co dałoby mi tę samą moc "zadeklaruj i wyrenderuj", ale dla dowolnych widoków zasilanych przez API.
Więc zbudowałem system blueprintów.
Główna idea: strona to po prostu słownik
Fundamentalny wniosek jest taki, że każda strona administracyjna działa według tego samego schematu: pobierz dane z API, wyświetl je w tabeli z kolumnami, pozwól użytkownikom filtrować i sortować, udostępnij akcje na wierszach oraz opcjonalnie umożliw edycję inline. Skoro schemat jest zawsze taki sam, jedyne, co zmienia się między stronami, to konfiguracja.
Blueprint to słownik w Pythonie, który opisuje wszystko na temat strony: jakie dane pobrać, jakie kolumny pokazać, jakie filtry zaoferować, jakie akcje są dostępne oraz kto ma uprawnienia, by co zobaczyć. Jeden generyczny szablon i jeden moduł JavaScript renderują każdą stronę - blueprint po prostu mówi im, co wyrenderować.
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,
}
To kompletna strona administracyjna. Tabela z sześcioma kolumnami, trzy filtry, przycisk edycji z kontrolą uprawnień, globalne wyszukiwanie i automatyczna rejestracja w menu. Bez pliku szablonu. Bez wzorca URL do napisania. Bez JavaScriptu do skonfigurowania. Dekorator obsługuje umiejscowienie w menu i generowanie URL. Widok generyczny obsługuje renderowanie.
Rejestr blueprintów: dekoratory, które budują nawigację
Dekorator @register_blueprint robi więcej niż tylko przechowywanie funkcji w słowniku. Buduje całą strukturę nawigacji aplikacji:
# Globalny rejestr blueprintów
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. Zarejestruj w globalnym rejestrze
BLUEPRINTS[name] = func
# 2. Automatycznie wygeneruj URL: "accounts" -> /int-data/accounts/
url_name = name.replace("_", "-")
url = reverse_lazy("int_view", args=[url_name])
# 3. Zarejestruj w hierarchii menu
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
Gdy nowy zespół aplikacji dodaje blueprint, menu w bocznym pasku aktualizuje się automatycznie. Żadnej ręcznej konfiguracji menu, żadnego utrzymywania tablicy routingu. Blueprint jest rejestracją. To ta sama zasada co w admin.site.register() w panelu admina Django, ale zastosowana do całego systemu nawigacji aplikacji.
Bezpieczeństwo typów: TypedDict jako żywa dokumentacja
Jedną z decyzji, która natychmiast się opłaciła, było użycie TypedDict dla każdej struktury blueprintu. Zamiast przekazywać zwykłe słowniki i mieć nadzieję, że klucze są poprawne, każdy blueprint jest zgodny z typowanym schematem, który IDE rozumieją:
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]]
Definicje TypedDict pełnią potrójną rolę: są adnotacjami typów dla mypy, podpowiedziami autouzupełniania dla IDE oraz dokumentacją dla deweloperów. Gdy ktoś pisze nowy schemat, jego edytor pokazuje każdą dostępną opcję, jej typ oraz to, czy jest wymagana. Literówka w kluczu? Sprawdzanie typów wychwyci ją, zanim strona się załaduje.
Nie jest to możliwe ze zwykłymi słownikami. Nie jest też możliwe z plikami konfiguracyjnymi YAML czy JSON - tracisz integrację z IDE w momencie, gdy opuszczasz Pythona. Trzymanie schematów jako typowanych słowników Pythona daje ci elastyczność konfiguracji przy zachowaniu bezpieczeństwa kodu.
System filtrów: komponowalne konstruktory zapytań
Filtry to jedna z najbardziej żmudnych części każdej tabeli danych. Każdy filtr potrzebuje komponentu UI, parametru zapytania i obsługi po stronie backendu. Pomnóż to przez ponad 20 obsługiwanych przez nas typów filtrów i 40 stron, które ich używają, a otrzymasz kombinatoryczną eksplozję powtarzalnego kodu.
System filtrów typu blueprint rozwiązuje to za pomocą klasy Filter, która udostępnia metody statyczne dla każdego typu filtra:
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"),
]
Każda metoda Filter zwraca słownik, który renderer tabeli w JavaScript potrafi obsłużyć. Deweloper nigdy nie pisze HTML filtrów, nigdy nie podłącza obsługi zdarzeń, nigdy nie buduje ciągów zapytań. Deklaruje, co chce filtrować, a system zajmuje się resztą. Operator splat (*Filter.NUMBER_RANGE()) jest szczególnie satysfakcjonujący - pojedyncze wywołanie rozwija się w dwie osobne konfiguracje filtrów dla minimum i maksimum.
Form Blueprints: formularze Django bez klas formularzy
Blueprint tabeli obsługuje wyświetlanie danych. Ale strony administracyjne potrzebują też formularzy - do tworzenia, edycji oraz akcji masowych. Tradycyjne podejście w Django to napisanie klasy forms.Form lub ModelForm dla każdej operacji. Przy 40 stronach i wielu akcjach na każdej to mnóstwo klas formularzy.
Blueprinty formularzy eliminują je całkowicie. Formularz jest zdefiniowany jako słownik ze specyfikacjami pól, a klasa DynamicForm generuje formularz Django w czasie wykonania:
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
Klasa DynamicForm odczytuje schemat (blueprint) i tworzy prawdziwe pola formularza Django z odpowiednimi widgetami, walidacją i układem. Mapuje InputsFields.CHAR_FIELD na forms.CharField, InputWidgets.TEXT_INPUT na forms.TextInput oraz obsługuje listy autouzupełniania zasilane API poprzez Select2. Klucz permission przy każdym polu decyduje o tym, czy bieżący użytkownik może to pole edytować - jeśli nie ma odpowiedniego uprawnienia, pole renderuje się jako tylko do odczytu, zamiast być ukrywane.
Silnik DynamicForm: od schematu do formularza Django
Magia dzieje się w klasie DynamicForm, która rozszerza forms.Form Django i dynamicznie tworzy pola na podstawie konfiguracji blueprintu:
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)
Klasa FormRenderer bierze następnie ten formularz, stosuje ograniczenia pól oparte na uprawnieniach (czyniąc pola tylko do odczytu dla użytkowników bez wymaganego uprawnienia), buduje adres URL wysyłki na podstawie api_config ze schematu i renderuje całość w modalu Bootstrap za pomocą Crispy Forms. Cały potok - od słownika do wyrenderowanego modalu z wysyłką AJAX - nie wymaga od dewelopera ani linijki kodu szablonu.
Przepływ żądań: jeden widok obsługuje 40 stron
Cały system blueprintów jest routowany przez pojedynczy widok Django. Każda strona w back-office trafia do tego samego wzorca URL, tej samej funkcji widoku i tego samego szablonu:
# urls.py - Jeden wzorzec URL dla wszystkiego
urlpatterns = [
path("int-data/<str:view_blueprint_name>/",
views.int_view, name="int_view"),
]
# views.py - Jedna funkcja widoku dla wszystkiego
@login_required
def int_view(request, view_blueprint_name) -> HttpResponse:
blueprint_key = view_blueprint_name.replace("-", "_")
# Wyszukaj funkcję blueprint
blueprint = BLUEPRINTS.get(blueprint_key)
if not blueprint:
raise PermissionDenied(f"Blueprint '{blueprint_key}' not found")
# Sprawdź uprawnienia na poziomie sekcji
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
# Wykonaj funkcję blueprint i wyrenderuj
context = blueprint(request)
return render(request, "base_table_page.html", context)
Adres URL /int-data/accounts/ wywołuje accounts_bp(request). Adres URL /int-data/balances/ wywołuje balances_bp(request). Ten sam widok, ten sam szablon, inne dane. Szablon otrzymuje słownik schematu jako kontekst, serializuje go do znaczników script z JSON, a renderer tabeli w JavaScript przejmuje kontrolę.
Uprawnienia na każdej warstwie
System schematów egzekwuje uprawnienia na trzech poziomach, a wszystkie są zadeklarowane w konfiguracji schematu - a nie w oddzielnych sprawdzeniach uprawnień rozsianych po widokach:
- Poziom sekcji: Czy użytkownik może w ogóle uzyskać dostęp do tej strony? Sprawdzane w
int_view, zanim funkcja schematu zostanie w ogóle wywołana. - Poziom akcji: Czy użytkownik może zobaczyć przycisk Edytuj? Przycisk Zatwierdź? Każda akcja w schemacie ma klucz
permission. Akcje są filtrowane po stronie klienta na podstawie roli użytkownika. - Poziom pola: Czy użytkownik może edytować pole waluty? Stawkę prowizji? Każde pole formularza ma opcjonalny klucz
permission. Jeśli użytkownik go nie ma, pole renderuje się jako tylko do odczytu z wizualnym wskaźnikiem - nie jest ukrywane, ponieważ użytkownik powinien wciąż widzieć dane.
To warstwowe podejście oznacza, że pojedyncza definicja schematu obsługuje zarówno interfejs użytkownika, jak i kontrolę dostępu. Nie ma oddzielnego pliku uprawnień do utrzymania, nie ma stosu dekoratorów na widokach, nie ma warunków w szablonach sprawdzających {% if perms.app.can_edit %}.
Zagnieżdżony dostęp do pól i formatery
Dane ze świata rzeczywistego nigdy nie są płaskie. Konto ma walutę, która ma nazwę. Zamówienie ma tradera, który ma adres e-mail. System nagłówków blueprintu obsługuje dostęp do zagnieżdżonych pól za pomocą notacji kropkowej:
"headers": {
"ID": "id", # Proste pole
"Currency": "currency.asset_name", # Zagnieżdżone: obj.currency.asset_name
"Group": "group.name", # Zagnieżdżone: obj.group.name
"Risk Profile": "group.risk_profile.name", # Trzy poziomy w głąb
"Is Active": { # Konfiguracja zaawansowana
"field": "is_active",
"is_editable": True, # Edytowalne w miejscu
"permission": "accounts_can_update",
},
"Amount": {
"field": "amount",
"round": True, # Zaokrąglaj wartości liczbowe
"formatter_args": (2,), # 2 miejsca po przecinku
},
},
# Formattery przekształcają surowe wartości w wartości wyświetlane
"formatters": {
"Is Active": "ToBool", # true/false -> znacznik/krzyżyk
"Account": "ToAccountLink", # ID -> klikalny odnośnik
"Created At": "ToDateTime", # ciąg ISO -> sformatowana data
}
Renderer tabeli w JavaScript rozwiązuje "currency.asset_name", przechodząc po zagnieżdżonym obiekcie z odpowiedzi API. Formatery to funkcje JavaScript rejestrowane po nazwie - schemat jedynie deklaruje, którego formatera użyć dla której kolumny. Dodanie nowego formatera to pojedyncza definicja funkcji, która natychmiast staje się dostępna dla każdego schematu w systemie.
Akcje globalne: operacje masowe na podstawie konfiguracji
Jedną z najpotężniejszych funkcji są akcje globalne - operacje zbiorcze działające na zaznaczonych wierszach. Użytkownik zaznacza wiele kont, klika „Zmień status ryzyka”, wypełnia formularz w oknie modalnym, a wszystkie zaznaczone konta są aktualizowane w jednym wywołaniu API.
Cały przepływ jest skonfigurowany w blueprincie:
"global_actions": {
"risk_status": {
"permission": "account_risk_update",
"title": "Zmień status ryzyka",
"description": "Zaktualizuj status ryzyka dla wybranych kont",
"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": "Aktywuj / Dezaktywuj",
"description": "Przełącz status aktywności",
"form": serialize_blueprint_form("account_is_active_bulk_action"),
"app": "account",
"url_name": reverse_lazy("account:bulk_updates_v1"),
"method": "PATCH",
},
}
Funkcja serialize_blueprint_form bierze nazwę schematu formularza, generuje formularz Django, renderuje go do HTML i serializuje do kontekstu strony. Gdy użytkownik klika akcję, JavaScript otwiera modal ze wstępnie wyrenderowanym formularzem, zbiera identyfikatory zaznaczonych wierszy i wysyła całość poprzez AJAX. Osiem akcji zbiorczych na stronie kont, każda z własnym formularzem, a jedynym kodem specyficznym dla każdej akcji jest jej definicja schematu.
Co ta architektura eliminuje
Po zbudowaniu ponad 40 stron z blueprintami, oto czego kodebaza nie zawiera:
- Zero szablonów specyficznych dla strony — każda strona administracyjna używa
base_table_page.html - Zero JavaScriptu specyficznego dla strony — renderer tabeli, obsługa filtrów i menedżer akcji są generyczne
- Zero ręcznych wzorców URL na stronę — jeden wzorzec URL z dynamicznym parametrem obsługuje wszystko
- Zero plików klas formularzy — formularze są generowane z blueprintów
- Zero ręcznej rejestracji menu — dekoratory automatycznie budują pasek boczny
Dodanie nowej strony administracyjnej to pojedynczy plik Pythona z udekorowaną funkcją zwracającą słownik. Żadnego nowego szablonu, żadnego nowego URL-a, żadnego nowego JavaScriptu, żadnej konfiguracji menu. 41. strona wymaga tego samego wysiłku co 2.
Rodowód wywodzący się z panelu administracyjnego Django
Ten system jest bezpośrednim potomkiem filozofii admina Django. Admin Django mówi: "Daj mi model, a zbuduję interfejs użytkownika." Blueprinty mówią: "Daj mi słownik, a zbuduję interfejs użytkownika."
Różnica polega na tym, że admin Django jest ściśle powiązany z modelami Django i ORM. Blueprinty są odsprzężone od źródła danych - działają z dowolnym punktem końcowym API, dowolnym formatem danych, dowolnym backendem. Mógłbyś skierować blueprint na API REST, punkt końcowy GraphQL albo statyczny plik JSON, a renderowanie byłoby identyczne.
Ale kluczowy wgląd jest ten sam: jeśli opiszesz strukturę swoich danych i swoje wzorce interakcji, framework może zbudować interfejs. Nie musisz ręcznie tworzyć każdej tabeli, każdego filtra, każdego formularza, każdego sprawdzenia uprawnień. Musisz opisać, czego chcesz, i pozwolić systemowi to wyrenderować.
Admin Django udowodnił, że jest to możliwe, w 2005 roku. Blueprinty dowodzą, że skaluje się to do złożonych, niestandardowych aplikacji back-office napędzanych API w 2025 roku.
Blueprint nie jest skrótem - jest stwierdzeniem, że strony admina to dane, a nie kod. W chwili, gdy to zaakceptujesz, szablony, wzorce URL-i, klasy formularzy i JavaScript stają się problemem frameworka, a nie Twoim.