Architecture 2026-04-24·Ievgenii Svyryd

Blueprints : comment j'ai remplacé des centaines de templates par des dictionnaires Python

Un système de blueprints qui transforme des dictionnaires Python en pages d'administration complètes — tableaux, filtres, formulaires, actions, permissions et menus — sans aucun template HTML. Comment un décorateur, un TypedDict et un contrat JSON ont éliminé toute une catégorie de travail frontend répétitif.

Blueprints : comment j'ai remplacé des centaines de templates par des dictionnaires Python

Le problème : 40 pages d'administration, un seul développeur

Je construisais un système de back-office pour une plateforme de trading. Les exigences étaient familières à quiconque a déjà construit des outils internes : comptes, soldes, ordres, commissions, rapports, tableaux de bord de risque - environ 40 vues de données distinctes, chacune avec filtrage, tri, actions, édition en ligne et permissions basées sur les rôles.

L'approche Django traditionnelle serait : écrire un template pour chaque page, une vue pour chaque page, des patterns d'URL pour chaque page, des classes de formulaire pour chaque action, et du JavaScript pour chaque interaction. Multipliez cela par 40, et vous vous retrouvez face à des mois de travail répétitif et à une base de code où modifier une colonne de tableau signifie éditer du HTML, du JavaScript et du Python dans trois fichiers différents.

J'avais déjà vu ce que l'admin Django pouvait faire avec l'introspection de modèles. Mais il ne s'agissait pas d'un admin standard - c'était un back-office sur mesure avec une logique métier complexe, des permissions personnalisées, des opérations en masse, et des tableaux de données pilotés par API. Le ModelAdmin de Django n'était pas assez flexible. Il me fallait quelque chose qui me donne la même puissance de « déclarer et rendre », mais pour des vues arbitraires pilotées par API.

J'ai donc construit le système de blueprints.

L'idée centrale : une page n'est qu'un dictionnaire

L'idée fondamentale est que chaque page d'administration suit le même schéma : récupérer des données depuis une API, les afficher dans un tableau avec des colonnes, laisser les utilisateurs filtrer et trier, proposer des actions sur les lignes et, éventuellement, permettre l'édition en ligne. Si le schéma est toujours le même, la seule chose qui change d'une page à l'autre, c'est la configuration.

Un blueprint est un dictionnaire Python qui décrit tout ce qui concerne une page : quelles données récupérer, quelles colonnes afficher, quels filtres proposer, quelles actions sont disponibles et qui a la permission de voir quoi. Un seul template générique et un seul module JavaScript rendent chaque page - le blueprint leur indique simplement quoi rendre.

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,
    }

Voilà une page d'admin complète. Un tableau à six colonnes, trois filtres, un bouton d'édition avec contrôle des permissions, une recherche globale et un enregistrement automatique dans le menu. Aucun fichier de template. Aucun schéma d'URL à écrire. Aucun JavaScript à configurer. Le décorateur gère le placement dans le menu et la génération des URL. La vue générique gère le rendu.

Le registre des Blueprints : des décorateurs qui construisent la navigation

Le décorateur @register_blueprint fait plus que stocker une fonction dans un dictionnaire. Il construit toute la structure de navigation de l'application :

# Registre global de blueprints
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. Enregistrement dans le registre global
        BLUEPRINTS[name] = func

        # 2. Génération automatique de l'URL : "accounts" -> /int-data/accounts/
        url_name = name.replace("_", "-")
        url = reverse_lazy("int_view", args=[url_name])

        # 3. Enregistrement dans la hiérarchie du 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

Lorsqu'une nouvelle équipe applicative ajoute un blueprint, le menu de la barre latérale se met à jour automatiquement. Pas de configuration manuelle du menu, pas de maintenance de table de routage. Le blueprint est l'enregistrement. C'est le même principe que le admin.site.register() de l'admin Django, mais appliqué à tout un système de navigation applicatif.

Sûreté des types : TypedDict comme documentation vivante

L'une des décisions qui s'est rentabilisée immédiatement a été l'utilisation de TypedDict pour chaque structure de blueprint. Au lieu de passer des dictionnaires ordinaires en espérant que les clés soient correctes, chaque blueprint se conforme à un schéma typé que les IDE comprennent :

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]]

Les définitions TypedDict remplissent une triple fonction : ce sont des annotations de type pour mypy, des indications d'autocomplétion pour les IDE, et de la documentation pour les développeurs. Lorsqu'on écrit un nouveau blueprint, l'éditeur indique chaque option disponible, son type, et si elle est obligatoire. Une clé mal orthographiée ? Le vérificateur de types la détecte avant même que la page ne se charge.

Ce n'est pas possible avec des dictionnaires ordinaires. Et ce n'est pas non plus possible avec des fichiers de configuration YAML ou JSON - on perd l'intégration à l'IDE dès l'instant où l'on quitte Python. Conserver les blueprints sous forme de dictionnaires Python typés offre la souplesse de la configuration avec la sûreté du code.

Le système de filtres : des constructeurs de requêtes composables

Les filtres sont l'une des parties les plus fastidieuses de tout tableau de données. Chaque filtre nécessite un composant d'interface, un paramètre de requête et un traitement côté backend. Multipliez cela par les plus de 20 types de filtres que nous prenons en charge et par les 40 pages qui les utilisent, et vous obtenez une explosion combinatoire de code répétitif.

Le système de filtres blueprint résout cela avec une classe Filter qui fournit des méthodes statiques pour chaque type de filtre :

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"),
]

Chaque méthode Filter renvoie un dictionnaire que le moteur de rendu de tableau en JavaScript sait traiter. Le développeur n'écrit jamais de HTML de filtre, ne câble jamais de gestionnaires d'événements, ne construit jamais de chaînes de requête. Il déclare ce qu'il veut filtrer et le système s'occupe du reste. L'opérateur de décomposition (*Filter.NUMBER_RANGE()) est particulièrement satisfaisant - un seul appel se développe en deux configurations de filtre distinctes pour le minimum et le maximum.

Form Blueprints : des formulaires Django sans classes de formulaire

Le blueprint de tableau gère l'affichage des données. Mais les pages d'administration ont aussi besoin de formulaires - des formulaires de création, d'édition et d'action en masse. L'approche Django traditionnelle consiste à écrire une classe forms.Form ou ModelForm pour chaque opération. Avec 40 pages et plusieurs actions chacune, cela fait beaucoup de classes de formulaires.

Les blueprints de formulaire les éliminent entièrement. Un formulaire est défini comme un dictionnaire avec des spécifications de champs, et une classe DynamicForm génère le formulaire Django à l'exécution :

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

La classe DynamicForm lit le blueprint et crée de véritables champs de formulaire Django avec les widgets, la validation et la mise en page appropriés. Elle associe InputsFields.CHAR_FIELD à forms.CharField, InputWidgets.TEXT_INPUT à forms.TextInput, et gère les menus déroulants d'autocomplétion alimentés par API via Select2. La clé permission sur chaque champ détermine si l'utilisateur courant peut modifier ce champ - s'il ne dispose pas de la permission, le champ s'affiche en lecture seule au lieu d'être masqué.

Le moteur DynamicForm : du plan au formulaire Django

La magie opère dans la classe DynamicForm, qui étend forms.Form de Django et crée dynamiquement des champs à partir de la configuration du 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)

La classe FormRenderer reprend alors ce formulaire, applique les restrictions de champs basées sur les permissions (rendant les champs en lecture seule pour les utilisateurs qui n'ont pas la permission requise), construit une URL de soumission à partir de l'api_config du blueprint, puis assemble le tout dans une fenêtre modale Bootstrap via Crispy Forms. L'ensemble du pipeline - du dictionnaire jusqu'à la fenêtre modale rendue avec soumission AJAX - ne requiert aucun code de template de la part du développeur.

Le flux de requêtes : une seule vue dessert 40 pages

L'ensemble du système de blueprints est routé via une unique vue Django. Chaque page du back-office atteint le même motif d'URL, la même fonction de vue et le même template :

# 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)

L'URL /int-data/accounts/ appelle accounts_bp(request). L'URL /int-data/balances/ appelle balances_bp(request). Même vue, même template, données différentes. Le template reçoit le dictionnaire du blueprint comme contexte, le sérialise en balises script JSON, et le moteur de rendu de table en JavaScript prend le relais.

Permissions à chaque couche

Le système de blueprints applique les permissions à trois niveaux, et tous sont déclarés dans la configuration du blueprint - et non dans des vérifications de permissions séparées dispersées à travers les vues :

  • Niveau section : L'utilisateur peut-il accéder à cette page tout court ? Vérifié dans int_view avant même que la fonction de blueprint ne soit appelée.
  • Niveau action : L'utilisateur peut-il voir le bouton Modifier ? Le bouton Approuver ? Chaque action du blueprint possède une clé permission. Les actions sont filtrées côté client en fonction du rôle de l'utilisateur.
  • Niveau champ : L'utilisateur peut-il modifier le champ devise ? Le taux de commission ? Chaque champ de formulaire possède une clé permission optionnelle. Si l'utilisateur ne la possède pas, le champ s'affiche en lecture seule avec un indicateur visuel - non masqué, car il doit tout de même voir la donnée.

Cette approche en couches signifie qu'une seule définition de blueprint gère à la fois l'interface et le contrôle d'accès. Il n'y a pas de fichier de permissions séparé à maintenir, pas de pile de décorateurs sur les vues, pas de conditions de template vérifiant {% if perms.app.can_edit %}.

Accès aux champs imbriqués et formateurs

Les données du monde réel ne sont jamais plates. Un compte a une devise, qui a un nom. Une commande a un trader, qui a un e-mail. Le système d'en-têtes du blueprint gère l'accès aux champs imbriqués avec la notation par points :

"headers": {
    "ID": "id",                          # Champ simple
    "Currency": "currency.asset_name",    # Imbriqué : obj.currency.asset_name
    "Group": "group.name",               # Imbriqué : obj.group.name
    "Risk Profile": "group.risk_profile.name",  # Trois niveaux d'imbrication
    "Is Active": {                        # Configuration avancée
        "field": "is_active",
        "is_editable": True,              # Modifiable en ligne
        "permission": "accounts_can_update",
    },
    "Amount": {
        "field": "amount",
        "round": True,                    # Arrondir les valeurs numériques
        "formatter_args": (2,),           # 2 décimales
    },
},

# Les formateurs transforment les valeurs brutes en valeurs d'affichage
"formatters": {
    "Is Active": "ToBool",       # true/false -> coche/croix
    "Account": "ToAccountLink",  # ID -> lien cliquable
    "Created At": "ToDateTime",  # chaîne ISO -> date formatée
}

Le moteur de rendu de table en JavaScript résout "currency.asset_name" en parcourant l'objet imbriqué de la réponse de l'API. Les formateurs sont des fonctions JavaScript enregistrées par nom - le blueprint se contente de déclarer quel formateur utiliser pour quelle colonne. Ajouter un nouveau formateur revient à définir une seule fonction, et celle-ci devient instantanément disponible pour chaque blueprint du système.

Actions globales : opérations groupées à partir de la configuration

L'une des fonctionnalités les plus puissantes est celle des actions globales - des opérations groupées qui s'appliquent aux lignes sélectionnées. L'utilisateur coche plusieurs comptes, clique sur « Change Risk Status », remplit un formulaire modal, et tous les comptes sélectionnés sont mis à jour en un seul appel API.

L'ensemble du flux est configuré dans le blueprint :

"global_actions": {
    "risk_status": {
        "permission": "account_risk_update",
        "title": "Modifier le statut de risque",
        "description": "Mettre à jour le statut de risque des comptes sélectionnés",
        "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": "Activer / Désactiver",
        "description": "Basculer le statut actif",
        "form": serialize_blueprint_form("account_is_active_bulk_action"),
        "app": "account",
        "url_name": reverse_lazy("account:bulk_updates_v1"),
        "method": "PATCH",
    },
}

La fonction serialize_blueprint_form prend un nom de blueprint de formulaire, génère le formulaire Django, le rend en HTML et le sérialise dans le contexte de la page. Lorsque l'utilisateur clique sur l'action, JavaScript ouvre une fenêtre modale contenant le formulaire pré-rendu, collecte les identifiants des lignes sélectionnées et soumet le tout via AJAX. Huit actions groupées sur la page des comptes, chacune avec son propre formulaire, et le seul code spécifique à chaque action est sa définition de blueprint.

Ce que cette architecture élimine

Après avoir construit plus de 40 pages avec des blueprints, voici ce que la base de code ne contient pas :

  • Zéro template spécifique à une page - chaque page d'admin utilise base_table_page.html
  • Zéro JavaScript spécifique à une page - le moteur de rendu de tableau, le gestionnaire de filtres et le gestionnaire d'actions sont génériques
  • Zéro pattern d'URL manuel par page - un seul pattern d'URL avec un paramètre dynamique dessert tout
  • Zéro fichier de classe de formulaire - les formulaires sont générés à partir des blueprints
  • Zéro enregistrement manuel de menu - des décorateurs construisent la barre latérale automatiquement

Ajouter une nouvelle page d'admin, c'est un unique fichier Python avec une fonction décorée qui retourne un dictionnaire. Pas de nouveau template, pas de nouvelle URL, pas de nouveau JavaScript, pas de configuration de menu. La 41e page demande le même effort que la 2e.

L'héritage de l'admin Django

Ce système est un descendant direct de la philosophie de l'admin Django. L'admin Django dit : « Donnez-moi un modèle et je construirai l'interface. » Les Blueprints disent : « Donnez-moi un dictionnaire et je construirai l'interface. »

La différence est que l'admin Django est étroitement couplé aux modèles Django et à l'ORM. Les Blueprints sont découplés de la source de données - ils fonctionnent avec n'importe quel point de terminaison d'API, n'importe quel format de données, n'importe quel backend. Vous pourriez pointer un blueprint vers une API REST, un point de terminaison GraphQL ou un fichier JSON statique, et le rendu serait identique.

Mais l'intuition centrale reste la même : si vous décrivez la structure de vos données et vos schémas d'interaction, le framework peut construire l'interface. Vous n'avez pas besoin de fabriquer à la main chaque tableau, chaque filtre, chaque formulaire, chaque vérification de permission. Vous devez décrire ce que vous voulez, et laisser le système le rendre.

L'admin Django a prouvé que c'était possible en 2005. Les Blueprints prouvent que cela passe à l'échelle pour des applications de back-office complexes, personnalisées et pilotées par API en 2025.

Un blueprint n'est pas un raccourci : c'est l'affirmation que les pages d'administration sont des données, pas du code. Dès l'instant où vous l'acceptez, les templates, les motifs d'URL, les classes de formulaire et le JavaScript deviennent tous le problème du framework, pas le vôtre.

Me contacter →