Architecture 2026-04-24·Ievgenii Svyryd

Blueprints: cómo reemplacé cientos de plantillas con diccionarios de Python

Un sistema de blueprints que convierte diccionarios de Python en páginas de administración completas -tablas, filtros, formularios, acciones, permisos y menús- sin plantillas HTML. Cómo un decorador, un TypedDict y un contrato JSON eliminaron toda una categoría de trabajo repetitivo de frontend.

Blueprints: cómo reemplacé cientos de plantillas con diccionarios de Python

El problema: 40 páginas de administración, un solo desarrollador

Estaba construyendo un sistema de back-office para una plataforma de trading. Los requisitos le resultarán familiares a cualquiera que haya construido herramientas internas: cuentas, saldos, órdenes, comisiones, informes, paneles de riesgo; unas 40 vistas de datos distintas, cada una con filtrado, ordenación, acciones, edición inline y permisos basados en roles.

El enfoque tradicional de Django sería: escribir una plantilla para cada página, una vista para cada página, patrones de URL para cada página, clases de formulario para cada acción y JavaScript para cada interacción. Multiplica eso por 40 y estás ante meses de trabajo repetitivo y una base de código donde cambiar una columna de una tabla significa editar HTML, JavaScript y Python en tres archivos distintos.

Ya había visto lo que Django admin podía hacer con la introspección de modelos. Pero esto no era un admin estándar: era un back-office personalizado con lógica de negocio compleja, permisos personalizados, operaciones masivas y tablas de datos gestionadas por API. El ModelAdmin de Django admin no era lo bastante flexible. Necesitaba algo que me diera el mismo poder de "declarar y renderizar", pero para vistas arbitrarias gestionadas por API.

Así que construí el sistema de blueprints.

La idea central: una página no es más que un diccionario

La idea fundamental es que cada página de administración sigue el mismo patrón: obtener datos de una API, mostrarlos en una tabla con columnas, permitir que los usuarios filtren y ordenen, ofrecer acciones sobre las filas y, opcionalmente, permitir la edición en línea. Si el patrón es siempre el mismo, lo único que cambia entre páginas es la configuración.

Un blueprint es un diccionario de Python que describe todo sobre una página: qué datos obtener, qué columnas mostrar, qué filtros ofrecer, qué acciones están disponibles y quién tiene permiso para ver qué. Una plantilla genérica y un módulo de JavaScript renderizan cada página; el blueprint simplemente les indica qué renderizar.

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

Eso es una página de administración completa. Una tabla con seis columnas, tres filtros, un botón de edición con control de permisos, búsqueda global y registro automático en el menú. Sin archivo de plantilla. Sin patrón de URL que escribir. Sin JavaScript que configurar. El decorador se encarga de la ubicación en el menú y de la generación de la URL. La vista genérica se encarga del renderizado.

El registro de blueprints: decoradores que construyen la navegación

El decorador @register_blueprint hace más que almacenar una función en un diccionario. Construye toda la estructura de navegación de la aplicación:

# Registro 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. Registrar en el registro global
        BLUEPRINTS[name] = func

        # 2. Autogenerar URL: "accounts" -> /int-data/accounts/
        url_name = name.replace("_", "-")
        url = reverse_lazy("int_view", args=[url_name])

        # 3. Registrar en la jerarquía del menú
        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

Cuando un nuevo equipo de aplicación añade un blueprint, el menú de la barra lateral se actualiza automáticamente. Sin configuración manual de menús, sin mantenimiento de tablas de enrutamiento. El blueprint es el registro. Este es el mismo principio que el admin.site.register() del admin de Django, pero aplicado a todo un sistema de navegación de la aplicación.

Seguridad de tipos: TypedDict como documentación viva

Una de las decisiones que se amortizó de inmediato fue usar TypedDict para cada estructura de blueprint. En lugar de pasar diccionarios planos y esperar que las claves sean correctas, cada blueprint se ajusta a un esquema tipado que los IDEs entienden:

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

Las definiciones TypedDict cumplen una triple función: son anotaciones de tipo para mypy, sugerencias de autocompletado para los IDE y documentación para los desarrolladores. Cuando alguien escribe un nuevo blueprint, su editor le indica cada opción disponible, su tipo y si es obligatoria. ¿Escribiste mal una clave? El verificador de tipos lo detecta antes de que la página se cargue.

Esto no es posible con diccionarios simples. Y tampoco es posible con archivos de configuración YAML o JSON: pierdes la integración con el IDE en el momento en que sales de Python. Mantener los blueprints como diccionarios de Python tipados te da la flexibilidad de la configuración con la seguridad del código.

El sistema de filtros: constructores de consultas componibles

Los filtros son una de las partes más tediosas de cualquier tabla de datos. Cada filtro necesita un componente de interfaz, un parámetro de consulta y un manejo en el backend. Multiplica eso por los más de 20 tipos de filtro que soportamos y las 40 páginas que los usan, y tienes una explosión combinatoria de código repetitivo.

El sistema de filtros del blueprint resuelve esto con una clase Filter que proporciona métodos estáticos para cada tipo de filtro:

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

Cada método Filter devuelve un diccionario que el renderizador de tablas en JavaScript sabe cómo manejar. El desarrollador nunca escribe el HTML del filtro, nunca conecta manejadores de eventos, nunca construye cadenas de consulta. Declara lo que quiere filtrar y el sistema se encarga del resto. El operador de despliegue (*Filter.NUMBER_RANGE()) es especialmente satisfactorio - una sola llamada se expande en dos configuraciones de filtro separadas para el mínimo y el máximo.

Form Blueprints: formularios de Django sin clases de formulario

El blueprint de tabla gestiona la visualización de datos. Pero las páginas de administración también necesitan formularios: formularios de creación, edición y acciones masivas. El enfoque tradicional de Django es escribir una clase forms.Form o ModelForm para cada operación. Con 40 páginas y varias acciones cada una, eso supone un montón de clases de formulario.

Los blueprints de formulario las eliminan por completo. Un formulario se define como un diccionario con especificaciones de campos, y una clase DynamicForm genera el formulario de Django en tiempo de ejecución:

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 clase DynamicForm lee el blueprint y crea campos de formulario de Django reales con los widgets, la validación y el diseño adecuados. Mapea InputsFields.CHAR_FIELD a forms.CharField, InputWidgets.TEXT_INPUT a forms.TextInput, y gestiona los desplegables de autocompletado con tecnología de API mediante Select2. La clave permission de cada campo controla si el usuario actual puede editar ese campo: si carece del permiso, el campo se renderiza como de solo lectura en lugar de ocultarse.

El motor DynamicForm: del blueprint al Django Form

La magia ocurre en la clase DynamicForm, que extiende forms.Form de Django y crea dinámicamente campos a partir de la configuración del 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 tipos de campo en total
    }

    WIDGET_MAP = {
        InputWidgets.TEXT_INPUT: forms.TextInput,
        InputWidgets.SELECT: forms.Select,
        InputWidgets.SELECT2: Select2Widget,
        InputWidgets.CHECKBOX_INPUT: forms.CheckboxInput,
        # ... 15 tipos de widget en 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 -> campos de Django
        self._setup_crispy_form_helper()  # Diseño con 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"))
            # Construir kwargs: required, max_length, label, help_text...
            self.fields[field_config["name"]] = field_class(**kwargs)

La clase FormRenderer toma entonces este formulario, aplica las restricciones de campo basadas en permisos (haciendo que los campos sean de solo lectura para los usuarios que carecen del permiso requerido), construye una URL de envío a partir del api_config del blueprint y renderiza todo en un modal de Bootstrap mediante Crispy Forms. Toda la canalización, desde el diccionario hasta el modal renderizado con envío por AJAX, no requiere ningún código de plantilla por parte del desarrollador.

El flujo de solicitudes: una sola vista sirve 40 páginas

Todo el sistema de blueprints se enruta a través de una única vista de Django. Cada página del back-office llega al mismo patrón de URL, la misma función de vista y la misma plantilla:

# urls.py - Un único patrón de URL para todo
urlpatterns = [
    path("int-data/<str:view_blueprint_name>/",
         views.int_view, name="int_view"),
]

# views.py - Una única función de vista para todo
@login_required
def int_view(request, view_blueprint_name) -> HttpResponse:
    blueprint_key = view_blueprint_name.replace("-", "_")

    # Buscar la función del blueprint
    blueprint = BLUEPRINTS.get(blueprint_key)
    if not blueprint:
        raise PermissionDenied(f"Blueprint '{blueprint_key}' not found")

    # Comprobar los permisos a nivel de sección
    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

    # Ejecutar la función del blueprint y renderizar
    context = blueprint(request)
    return render(request, "base_table_page.html", context)

La URL /int-data/accounts/ llama a accounts_bp(request). La URL /int-data/balances/ llama a balances_bp(request). La misma vista, la misma plantilla, datos diferentes. La plantilla recibe el diccionario del blueprint como contexto, lo serializa en etiquetas script JSON y el renderizador de tablas en JavaScript se hace cargo.

Permisos en cada capa

El sistema de blueprints impone permisos en tres niveles, y todos ellos se declaran en la configuración del blueprint, no en comprobaciones de permisos separadas dispersas por las vistas:

  • Nivel de sección: ¿Puede el usuario acceder a esta página en absoluto? Se comprueba en int_view antes incluso de que se llame a la función del blueprint.
  • Nivel de acción: ¿Puede el usuario ver el botón Editar? ¿El botón Aprobar? Cada acción del blueprint tiene una clave permission. Las acciones se filtran del lado del cliente en función del rol del usuario.
  • Nivel de campo: ¿Puede el usuario editar el campo de moneda? ¿La tasa de comisión? Cada campo de formulario tiene una clave permission opcional. Si el usuario carece de ella, el campo se renderiza como de solo lectura con un indicador visual, no oculto, porque debería seguir viendo los datos.

Este enfoque por capas significa que una única definición de blueprint gestiona tanto la interfaz de usuario como el control de acceso. No hay ningún archivo de permisos separado que mantener, ni ninguna pila de decoradores en las vistas, ni condicionales de plantilla que comprueben {% if perms.app.can_edit %}.

Acceso a campos anidados y formateadores

Los datos del mundo real nunca son planos. Una cuenta tiene una divisa, que tiene un nombre. Un pedido tiene un trader, que tiene un correo electrónico. El sistema de encabezados del blueprint gestiona el acceso a campos anidados con notación de punto:

"headers": {
    "ID": "id",                          # Campo simple
    "Currency": "currency.asset_name",    # Anidado: obj.currency.asset_name
    "Group": "group.name",               # Anidado: obj.group.name
    "Risk Profile": "group.risk_profile.name",  # Tres niveles de profundidad
    "Is Active": {                        # Configuración avanzada
        "field": "is_active",
        "is_editable": True,              # Editable en línea
        "permission": "accounts_can_update",
    },
    "Amount": {
        "field": "amount",
        "round": True,                    # Redondea valores numéricos
        "formatter_args": (2,),           # 2 posiciones decimales
    },
},

# Los formateadores transforman los valores brutos en valores de visualización
"formatters": {
    "Is Active": "ToBool",       # true/false -> marca de verificación/cruz
    "Account": "ToAccountLink",  # ID -> enlace en el que se puede hacer clic
    "Created At": "ToDateTime",  # cadena ISO -> fecha formateada
}

El renderizador de tablas en JavaScript resuelve "currency.asset_name" recorriendo el objeto anidado de la respuesta de la API. Los formateadores son funciones de JavaScript registradas por nombre: el blueprint solo declara qué formateador usar para qué columna. Añadir un nuevo formateador es una única definición de función, y queda disponible al instante para cada blueprint del sistema.

Acciones globales: operaciones masivas desde la configuración

Una de las funcionalidades más potentes son las acciones globales: operaciones masivas que actúan sobre las filas seleccionadas. El usuario marca varias cuentas, hace clic en "Cambiar estado de riesgo", rellena un formulario modal y todas las cuentas seleccionadas se actualizan en una sola llamada a la API.

Todo el flujo se configura en el blueprint:

"global_actions": {
    "risk_status": {
        "permission": "account_risk_update",
        "title": "Cambiar estado de riesgo",
        "description": "Actualiza el estado de riesgo de las cuentas seleccionadas",
        "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": "Activar / Desactivar",
        "description": "Alterna el estado activo",
        "form": serialize_blueprint_form("account_is_active_bulk_action"),
        "app": "account",
        "url_name": reverse_lazy("account:bulk_updates_v1"),
        "method": "PATCH",
    },
}

La función serialize_blueprint_form toma el nombre de un blueprint de formulario, genera el formulario de Django, lo renderiza a HTML y lo serializa en el contexto de la página. Cuando el usuario hace clic en la acción, JavaScript abre un modal con el formulario prerrenderizado, recopila los ID de las filas seleccionadas y envía todo mediante AJAX. Ocho acciones masivas en la página de cuentas, cada una con su propio formulario, y el único código específico de cada acción es su definición de blueprint.

Lo que esta arquitectura elimina

Después de construir más de 40 páginas con blueprints, esto es lo que el código no contiene:

  • Cero plantillas específicas de página: cada página del admin usa base_table_page.html
  • Cero JavaScript específico de página: el renderizador de tablas, el gestor de filtros y el gestor de acciones son genéricos
  • Cero patrones de URL manuales por página: un único patrón de URL con un parámetro dinámico lo sirve todo
  • Cero archivos de clases de formulario: los formularios se generan a partir de los blueprints
  • Cero registro manual de menús: los decoradores construyen la barra lateral automáticamente

Añadir una nueva página de admin es un único archivo de Python con una función decorada que devuelve un diccionario. Ninguna plantilla nueva, ninguna URL nueva, ningún JavaScript nuevo, ninguna configuración de menú. La página número 41 requiere el mismo esfuerzo que la número 2.

El linaje del admin de Django

Este sistema es un descendiente directo de la filosofía del admin de Django. El admin de Django dice: "Dame un modelo y construiré la interfaz." Los blueprints dicen: "Dame un diccionario y construiré la interfaz."

La diferencia es que el admin de Django está fuertemente acoplado a los modelos de Django y al ORM. Los blueprints están desacoplados de la fuente de datos: funcionan con cualquier endpoint de API, cualquier formato de datos, cualquier backend. Podrías apuntar un blueprint a una API REST, un endpoint de GraphQL o un archivo JSON estático, y el renderizado sería idéntico.

Pero la intuición central es la misma: si describes tu estructura de datos y tus patrones de interacción, el framework puede construir la interfaz. No necesitas confeccionar a mano cada tabla, cada filtro, cada formulario, cada comprobación de permisos. Necesitas describir lo que quieres, y dejar que el sistema lo renderice.

El admin de Django demostró que esto era posible en 2005. Los blueprints demuestran que escala a aplicaciones de back-office complejas, personalizadas y basadas en API en 2025.

Un blueprint no es un atajo, sino una afirmación de que las páginas de administración son datos, no código. En el momento en que aceptas eso, las plantillas, los patrones de URL, las clases de formulario y el JavaScript pasan a ser problema del framework, no tuyo.

Contacto →