Ir para o conteúdo

Painel admin

UI de gerenciamento no estilo Django montada sob /admin. Operadores entram com uma linha de usuário do próprio banco — não há store de senha de admin separado. Cada modelo registrado passa a ser navegável pelo navegador, então a porta do banco pode ficar fechada em redes privadas.

O que você ganha (paridade com o Django admin):

  • List view com busca, filtros ricos por campo (enum / FK / range de data) e colunas ordenáveis.
  • CRUD completo (criar / editar / excluir) e ações em massa.
  • Export CSV/JSON e widgets FK-select.
  • Dashboard com contagens de linhas + métricas de sistema.
  • MFA TOTP opcional no login.
  • Campos de upload de arquivo/imagem.
  • Trilha de auditoria carimbando created_by / updated_by.

Cobre também edição inline in-place dos filhos 1-N (Inline(editable=True)).

Requer o extra [admin]:

uv add "tempest-fastapi-sdk[admin]"

1. Modelo de usuário

Subclasse BaseUserModel para ganhar as quatro colunas que o backend de auth do admin espera (email, hashed_password, is_admin, last_login_at) em cima da linha padrão do BaseModel:

# src/db/models/user.py
from tempest_fastapi_sdk import BaseUserModel


class UserModel(BaseUserModel):
    __tablename__ = "users"   # scaffold convention; admin slug derives from __tablename__

set_password() / check_password() delegam ao PasswordUtils; normalize_email() deixa minúsculo e remove espaços. O is_active padrão (herdado do BaseModel) e o is_admin (default False) controlam o acesso — somente linhas is_active=True E is_admin=True podem entrar.

Faça o bootstrap do primeiro admin pela sua CLI / migração / script de seed. O script completo conecta um AsyncDatabaseManager, abre uma sessão, insere a linha e dá commit — exatamente o mesmo padrão que seus repositories seguem em runtime:

# scripts/create_admin.py
import asyncio

from tempest_fastapi_sdk import AsyncDatabaseManager

from src.core.settings import settings
from src.db.models import UserModel


async def main() -> None:
    db = AsyncDatabaseManager(settings.DATABASE_URL)
    await db.connect()
    try:
        async with db.get_session_context() as session:
            # ──────── the only admin-specific lines ────────
            admin = UserModel(email="root@example.com", is_admin=True)
            admin.set_password("hunter2")  # bcrypt via PasswordUtils
            session.add(admin)
            await session.commit()
    finally:
        await db.disconnect()


if __name__ == "__main__":
    asyncio.run(main())

As quatro linhas destacadas sob o comentário divisor são o único código de bootstrap específico de admin; tudo ao redor é o ciclo de vida async de DB padrão que o SDK já usa.

2. Registre suas classes de admin

AdminModel é uma instância de configuração tipada simples — a assinatura do construtor é o contrato (sem mágica de atributo de classe / metaclass), e todo campo aceita um atributo de coluna SQLAlchemy real (UserModel.email), então erros de digitação aparecem no seu editor em vez de em runtime. Os defaults funcionam de cara; passe os campos que quiser para enriquecer a list view:

# src/admin/site.py
from sqlalchemy import desc

from tempest_fastapi_sdk import AdminModel, AdminSite

from src.db.models import UserModel, OrderModel

site = AdminSite(
    title="MyApp Admin",
    brand="servus-backend-admin",     # texto centralizado no topo (opcional; default = title)
    index_subtitle="Site administration",
    site_url="https://myapp.com",     # optional outbound "View site" link
)

site.register(AdminModel(
    model=UserModel,
    list_display=[UserModel.email, UserModel.is_admin, UserModel.is_active, UserModel.last_login_at],
    list_filter=[UserModel.is_active, UserModel.is_admin],
    search_fields=[UserModel.email],
    readonly_fields=[UserModel.id, UserModel.hashed_password, UserModel.created_at, UserModel.updated_at],
    ordering=desc(UserModel.created_at),
    page_size=25,
))

Toda referência a campo também aceita uma string simples (list_display=["email", ...]) para configuração dinâmica, e ordering aceita uma coluna (ascendente), desc(column) / asc(column), ou uma string no estilo Django "-created_at". register retorna a instância e levanta ValueError em slug duplicado. Os slugs derivam por padrão do __tablename__ do modelo, para que URLs e tabelas do banco fiquem em sincronia.

Filtros automáticos por tipo de coluna

Cada campo em list_filter vira o widget certo conforme o tipo da coluna: boolean → dropdown Sim/Não; enum → dropdown com os membros; FK (cujo destino tem AdminModel registrado) → dropdown das linhas relacionadas (label pelo search_fields); date/datetime → dois inputs de data (de/até, range inclusivo); qualquer outra coluna → input de texto (igualdade). Tudo preserva busca/ordenação/paginação na URL.

Marca centralizada e customizável

O nome exibido no centro do header vem de brand (opcional). Sem ele, cai no title — então sites existentes não mudam. Use brand para mostrar um nome distinto (ex.: "servus-backend-admin") centralizado no topo de toda página. A sidebar é fixa e sobrepõe header e footer no desktop (z-index maior) — comportamento automático do CSS embutido, sem config.

2b. Atalho — registrar todos os modelos de uma vez (automap)

Em vez de um register por tabela, aponte automap para o pacote dos modelos e o SDK descobre e registra todo BaseModel concreto automaticamente. Bases abstratas (BaseUserModel e cia. — sem __tablename__) são puladas sozinhas:

# src/admin/site.py
from tempest_fastapi_sdk import AdminModel, AdminSite

site = AdminSite(title="MyApp Admin", brand="servus-backend-admin")

# Carrega TODAS as tabelas de src/db/models de uma vez:
site.automap("src.db.models")

Misture os dois estilos: registre à mão os modelos que precisam de config própria, depois deixe o automap preencher o resto (ele pula slugs já registrados por padrão):

# UserModel ganha config caprichada...

from tempest_fastapi_sdk import AdminModel

from src.admin import site
from src.db.models import UserModel


site.register(AdminModel(
    model=UserModel,
    list_display=[UserModel.email, UserModel.is_admin],
    search_fields=[UserModel.email],
))

# ...e o automap registra o resto com os defaults.
site.automap("src.db.models")

automap aceita: exclude=[...] (classe, nome de classe ou nome de tabela para esconder um modelo), skip_registered=False (levanta ValueError em colisão, igual register), e **admin_kwargs aplicados a todos (page_size=50, can_delete=False, ...). Para introspecção sem registrar, use a função discover_models("src.db.models") direto.

Config uniforme

Os **admin_kwargs do automap valem para todos os modelos descobertos. Quando um modelo precisa de list_display / search_fields próprios, registre-o à mão antes do automap (com skip_registered=True, o default).

3. Monte o router

# src/api/app.py
from fastapi import FastAPI

from tempest_fastapi_sdk import UserModelAuthBackend, make_admin_router

from src.admin.site import site
from src.api.dependencies import db   # singleton de src/api/dependencies/resources.py
from src.core.settings import settings
from src.db.models import UserModel

app = FastAPI()
app.include_router(
    make_admin_router(
        site,
        db=db,
        auth_backend=UserModelAuthBackend(UserModel),
        secret_key=settings.JWT_SECRET,          # scaffold reuses JWT_SECRET — pelo menos 32 bytes
        prefix="/admin",
        cookie_secure=not settings.DEBUG,        # True in production HTTPS
        show_logs=True,                          # liga a página de logs + item na sidebar
        log_dir=settings.LOG_DIR,                # mesmo dir passado pro configure_logging
    )
)

make_admin_router monta:

  • GET /admin/login, POST /admin/login, POST /admin/logout — fluxo de auth.
  • GET/POST /admin/mfa — desafio TOTP (segundo fator) entre a senha e o acesso, para principais com MFA habilitado.
  • GET /admin/ — dashboard: card por modelo com contagem de linhas + Browse/New, e um painel de métricas (CPU/RAM/disco via MetricsUtils). Painel ligado por default, omitido sem o extra [metrics], desligável com make_admin_router(show_metrics=False).
  • GET /admin/logs — logs da aplicação (quando show_logs=True): lê os arquivos JSON estruturados escritos pelo configure_logging(log_dir=…), com filtro por fonte (?source=), busca em texto (?q=) e paginação. Badges coloridos por nível. Quando ainda não há arquivos de log, mostra um estado vazio.
  • GET /admin/logs/export — export dos logs (?format=md|json): baixa a seleção filtrada como markdown (traceback em bloco cercado, pronto para colar em issue) ou JSON verbatim.
  • GET /admin/m/{slug}/ — list view com paginação + busca em texto livre (?q=) + filtros por campo (?filter_<field>=value) + ordenação por coluna clicável (?sort=<coluna>&dir=asc|desc).
  • GET /admin/m/{slug}/export.csv / export.json — exporta o resultado atual (respeitando busca/filtros/ordenação) como CSV ou JSON. Limite de linhas via make_admin_router(export_max_rows=…) (default 5000).
  • POST /admin/m/{slug}/bulk — ações em massa (delete / activate / deactivate + suas ações customizadas) nas linhas selecionadas.
  • GET/POST /admin/m/{slug}/new — criar registro (quando can_create).
  • GET /admin/m/{slug}/{identity} — detail view com botões Edit/Delete.
  • GET/POST /admin/m/{slug}/{identity}/edit — editar registro (quando can_edit).
  • POST /admin/m/{slug}/{identity}/delete — excluir registro (quando can_delete).
  • GET /admin/static/{path} — o CSS embutido do painel (admin.css).
  • GET /admin/_ssr/htmx.js — o HTMX empacotado no SDK (ssr/_static/htmx.min.js), servido sem sessão porque a página de login também o carrega. O caminho segue o prefix: com prefix="/backoffice", vira /backoffice/_ssr/htmx.js.

O painel não depende de terceiro para renderizar

Nenhuma página do admin busca script, folha de estilo ou fonte fora da sua aplicação: o <script> do HTMX aponta para {prefix}/_ssr/htmx.js, que serve byte a byte o arquivo empacotado no wheel. A exceção é o que você mesmo configurar: favicon_url, logo_url e custom_css_url do AdminTheme são carregados de onde apontarem. Indisponibilidade ou adulteração de uma CDN não alcança a origem do admin, que é a mesma do POST /admin/sql e do CRUD. Funciona só com o extra [admin] — o [ssr] não é necessário.

CSP continua sendo um passo à parte. O admin não envia Content-Security-Policy, e ainda usa <style> inline (o tema do AdminTheme), um <script> inline na list view, um onclick no autocomplete de FK e atributos style= nas barras de progresso. Servir o HTMX localmente tira a dependência de terceiro, mas não basta para uma política script-src 'self' sem 'unsafe-inline': com ela, essas partes param de funcionar.

Escrita (CRUD) + permissões

Create/edit/delete são controlados por flags no AdminModel: can_create / can_edit / can_delete (todas True por default; uma view desativada responde 404). Todo POST de escrita carrega o token CSRF da sessão, validado no servidor (403 em mismatch). Os widgets de campo são derivados do tipo da coluna — texto / textarea (strings longas) / number / checkbox / datetime-local / date / select para enums — com validação de obrigatórios + erros por campo re-renderizados no formulário. Escrita que o banco recusa (unique, FK, NOT NULL) volta pelo mesmo caminho: 400 com a mensagem do repositório (Conflict creating <Model>) no topo do form, nunca 500.

Ações em massa: a list view mostra checkboxes por linha + select-all e uma barra de ação (delete / activate / deactivate) que opera nas linhas marcadas via POST .../bulk (CSRF + flags can_delete/can_edit), apoiada em BaseRepository.delete_batch / bulk_update.

FK-select: uma coluna FK cujo destino tem AdminModel registrado vira um dropdown das linhas relacionadas (igual ao FK select do Django) no formulário, em vez de um input UUID cru. O label da opção vem do primeiro search_fields do admin referenciado (fallback: atributo name/title/email, depois o id). Limitado a 1000 linhas; FK para tabela não-gerenciada continua input UUID.

MFA no login: um principal com MFA habilitado (colunas totp_secret/totp_enabled_at do MFAMixin) passa por um desafio TOTP em /admin/mfa depois da senha — só um código válido libera o acesso. Habilite passando um usuário com MFA via UserModelAuthBackend(UserModel, mfa_issuer=...); backends customizados sobrescrevem mfa_enabled/verify_mfa.

Cada código vale uma entrada: o UserModelAuthBackend grava o passo aceito em totp_last_step (coluna do MFAMixin; backend customizado que guarda o próprio estado sobrescreve claim_mfa_step). E o desafio tem limite de códigos errados por principal: make_admin_router(mfa_throttle=...), por padrão 5 falhas em 900 s num InMemoryThrottleBackend; esgotado, /admin/mfa responde 429 com Retry-After antes de olhar o código, mesmo depois de um login novo. Com mais de um worker, passe um AttemptThrottle sobre o Redis.

Audit trail: create/edit pelo admin carimba created_by/updated_by (do AuditMixin) com o id do admin atuante; o detail mostra um painel Audit com timestamps e — quando o modelo tem as colunas de auditoria — o ator (UUID resolvido para nome via o auth backend). Modelos sem AuditMixin mostram só os timestamps.

Edição inline in-place dos filhos: veja Inline(editable=True) mais abaixo.

Ações customizadas (@admin_action)

Além das 3 fixas (activate / deactivate / delete), você registra ações próprias — uma função async decorada com @admin_action e passada em AdminModel(actions=[...]). Cada uma vira uma opção no dropdown de ações em massa, operando nas linhas marcadas.

from tempest_fastapi_sdk import (
    AdminActionContext,
    AdminActionResult,
    AdminModel,
    EmailUtils,
    admin_action,
)

from src.admin import site
from src.core.settings import settings
from src.db.models import UserModel

mailer = EmailUtils(**settings.email_kwargs())


@admin_action(label="Enviar boas-vindas")
async def send_welcome(ctx: AdminActionContext) -> AdminActionResult:
    """Roda nas linhas selecionadas; a mensagem é exibida na list view."""
    users = await ctx.repository.list(filters={"id": ctx.ids})
    for user in users:
        await mailer.send(
            user.email,
            subject="Bem-vindo",
            body=f"Olá, {user.email}! Sua conta está pronta.",
        )
    return AdminActionResult(f"{len(users)} e-mails enviados.")


site.register(AdminModel(model=UserModel, actions=[send_welcome]))

O handler recebe um AdminActionContext com:

Campo O que é
ids Identidades das linhas marcadas.
repository BaseRepository do modelo, na sessão do request.
db_session A sessão DB (pra trabalho além do repositório).
request O request inbound.
session A sessão do admin autenticado.
principal A linha do usuário admin que disparou a ação.

Retorne um AdminActionResult(message, category="success"|"error"|"warning") pra exibir um banner na list view (ou None pra não mostrar nada). A função fica diretamente chamável/testável — o decorator só anexa metadados. Use name= pra fixar o identificador (default: nome da função) e dangerous=True pra marcar ação destrutiva.

Campo de upload de arquivo / imagem

Uma coluna String que guarda o caminho/chave de um arquivo pode virar um input de upload no formulário. Liste a coluna em upload_fields e passe um upload_storage (os backends que o SDK já tem — LocalUploadStorage / MinIOUploadStorage). No submit, o arquivo é salvo no storage e a chave retornada é gravada na coluna.

from tempest_fastapi_sdk import AdminModel
from tempest_fastapi_sdk.utils import LocalUploadStorage

from src.admin import site
from src.db.models import DocumentModel


site.register(AdminModel(
    model=DocumentModel,
    upload_fields=[DocumentModel.attachment],   # coluna String que guarda a chave
    upload_storage=LocalUploadStorage("media/"),  # ou MinIOUploadStorage(...)
))

Instalação

Os backends de upload não vêm com o [admin]. LocalUploadStorage depende do extra [upload] — uv add "tempest-fastapi-sdk[upload]" (traz aiofiles); MinIOUploadStorage depende do [minio] — uv add "tempest-fastapi-sdk[minio]" (traz minio).

  • O form vira multipart/form-data automaticamente quando há upload_fields.
  • Create: arquivo obrigatório só se a coluna for NOT NULL e sem default.
  • Edit: sem arquivo novo → mantém o valor atual (mostra "Current: …"); com arquivo → substitui.
  • A coluna guarda a chave do storage (<slug>/<campo>/<uuid>.<ext>); use o upload_storage (ou UploadUtils) pra servir/baixar depois.

upload_fields exige upload_storage

Registrar upload_fields sem upload_storage levanta ValueError na construção do AdminModel — sem storage não há onde gravar o arquivo.

Navegação por sidebar + burger

Toda página autenticada tem uma sidebar persistente: Dashboard, um link por modelo registrado (agrupados em "Models") e, com show_logs=True, "Logs" em "System". O item da página atual fica destacado. No desktop a sidebar fica sempre visível à esquerda; no mobile (≤768px) ela vira off-canvas, aberta pelo ícone burger no header e fechada tocando no scrim — tudo CSS puro, sem JS.

Página de logs (show_logs=True)

GET /admin/logs lê os arquivos JSON estruturados que o configure_logging(log_dir=…) grava. Passe o mesmo log_dir para make_admin_router. A página oferece filtro por fonte (all/debug/info/warning/error/critical/500), busca por substring na mensagem e paginação, com badges coloridos por nível. É opt-in (show_logs=False por default) porque o payload expõe tracebacks e metadados de request — só habilite atrás do login do admin. Sem arquivos no log_dir, a página mostra um estado vazio.

Traceback de erros 500 e export para issue

Cada registro que carrega um traceback (os handlers do SDK logam com exc_info=True) vira um item clicável: a própria mensagem é o gatilho, então clicar em qualquer ponto da entrada revela o trace, com os campos de correlação do request (path, method, status_code, request_id) ao lado. <details>/<summary> puro, sem JS, recolhido por default para que uma página cheia de 500 continue escaneável — registro sem traceback não ganha gatilho nenhum.

GET /admin/logs/export?format=md|json baixa a seleção filtrada (mesmos ?source= e ?q= da página), do mais recente para o mais antigo, até 500 registros:

  • format=md — cada traceback vai num bloco cercado ```pytb, então sobrevive a um colar em issue/PR com a indentação intacta. O cabeçalho declara fonte, contagem, filtro aplicado e — quando o teto de 500 corta — quantos registros casavam no total, para um export parcial nunca se passar por completo.
  • format=json — os registros verbatim, com todo campo que a aplicação logou via extra=, para consumo por ferramenta.

Em telas abaixo de 600px a tabela vira cards empilhados (o header é escondido e cada célula se nomeia sozinha), então a mensagem e o traceback ficam na viewport sem scroll horizontal. As outras list views mantêm o scroll lateral, que serve para lista que se folheia.

O export herda o gate de sessão do admin: traceback é exatamente o payload que não pode ficar público. Para montar seu próprio export, render_entries_markdown e render_entries_json são exportados no nível do pacote.

Responsivo por padrão

Os templates + CSS embutidos são responsivos: em telas estreitas (≤600px) o header empilha, busca/filtros/ações viram full-width, as tabelas ganham scroll horizontal (nunca quebram o layout) e o grid do detail colapsa para uma coluna. Headers de coluna são clicáveis para alternar a ordenação (▲/▼).

Histórico de auditoria no detail (audit_model=)

O painel já carimba created_by / updated_by. Para ver o quê mudou (não só quem/quando), passe um audit_model — a mesma tabela BaseAuditLogModel que o BaseRepository já escreve — e o detail ganha uma timeline por registro.

Primeiro, a tabela de auditoria e um repository que a alimenta:

# src/db/models/audit.py
from tempest_fastapi_sdk import BaseAuditLogModel


class AuditLog(BaseAuditLogModel):
    __tablename__ = "audit_log"
# grave a trilha nas escritas (create/update/delete)

import asyncio

from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from tempest_fastapi_sdk import BaseRepository
from tempest_fastapi_sdk.db.audit import snapshot_model

from src.db.models import AuditLog, OrderModel, UserModel

current_user = UserModel(email="ana@example.com")
# Num serviço, a sessão real vem de `db.get_session_context()`; aqui, do SQLite.
session = AsyncSession(create_async_engine("sqlite+aiosqlite:///:memory:"))


repo = BaseRepository(session, model=OrderModel, audit_model=AuditLog)


async def main() -> None:
    """Run this example."""
    order = await repo.add_audited(OrderModel(...), actor=str(current_user.id))

    before = snapshot_model(order)
    order.status = "shipped"
    await repo.update_audited(order, before, actor=str(current_user.id))


asyncio.run(main())

Então plugue o mesmo audit_model no admin:

# src/admin/site.py

from tempest_fastapi_sdk import AdminModel

from src.admin import site
from src.db.models import AuditLog, OrderModel


site.register(AdminModel(model=OrderModel, audit_model=AuditLog))

No detail de cada pedido aparece um bloco History: uma entrada por mudança (create / update / delete, com cor), ator e data, e um diff campo-a-campo (antes → depois). As 50 entradas mais recentes, da mais nova para a mais antiga.

Como o match é feito

A viewer busca as linhas de auditoria onde entity == nome do modelo (ex.: "OrderModel") e entity_id == id do registro — exatamente o que add_audited / update_audited / delete_audited gravam. Sem audit_model, o detail continua igual (só os carimbos created_by/updated_by).

FK com autocomplete (autocomplete_fields=)

Por padrão um campo FK cujo alvo tem admin registrado vira um <select> com todas as linhas (capado em 1000). Numa tabela grande isso é inutilizável. Passe autocomplete_fields e o campo vira uma caixa de busca (HTMX) que consulta o alvo sob demanda:

# src/admin/site.py

from tempest_fastapi_sdk import AdminModel

from src.admin import site
from src.db.models import Company, Employee


site.register(AdminModel(model=Company, search_fields=[Company.name]))
site.register(
    AdminModel(model=Employee, autocomplete_fields=[Employee.company_id])
)

No form de Employee, company_id deixa de listar todas as empresas: você digita, o endpoint /admin/m/employee/autocomplete/company_id?q=… busca nos search_fields do admin de Company (ILIKE, OR, até 20 resultados) e mostra as opções; clicar fixa o id no campo. No edit, o rótulo da empresa atual já vem preenchido.

Requisitos

O alvo do FK precisa ter um AdminModel registrado (são os search_fields dele que guiam a busca). Sem autocomplete_fields, o FK continua um <select> (útil para tabelas pequenas).

Inlines — filhos 1-N no detail (inlines=)

Para ver (e chegar a) os registros que apontam pra este — pedidos de um cliente, membros de um time — declare inlines. O detail do pai passa a listar cada relação numa tabela, com link pro admin do filho e um botão Add que já pré-preenche o FK do pai.

# src/admin/site.py

from tempest_fastapi_sdk import AdminModel, Inline

from src.admin import site
from src.db.models import Member, Team


site.register(
    AdminModel(
        model=Team,
        inlines=[Inline(Member, Member.team_id, list_display=[Member.name])],
    )
)
site.register(AdminModel(model=Member))   # o filho precisa de admin p/ os links

No detail de um Team aparece a tabela Member com seus membros; "Add" abre o create de Member já com team_id preenchido (via query param). As colunas saem do list_display do Inline (ou, se omitido, do admin do filho). Até 50 linhas por inline.

O modelo filho precisa ter um AdminModel registrado para os links funcionarem; sem ele, as linhas aparecem só-leitura.

Edição in-place (editable=True)

Para editar os filhos sem sair do detail do pai, passe editable=True (e can_delete=True pra permitir remoção). A tabela vira um formset: uma linha de inputs por filho existente + uma linha em branco pra adicionar mais uma.

from tempest_fastapi_sdk import AdminModel, Inline

from src.admin import site
from src.db.models import Member, Team


site.register(
    AdminModel(
        model=Team,
        inlines=[
            Inline(
                Member,
                Member.team_id,
                editable=True,
                can_delete=True,
            )
        ],
    )
)
site.register(AdminModel(model=Member))   # can_edit / can_delete valem aqui

Ao salvar (POST /admin/m/<pai>/<id>/inlines/<filho>), tudo acontece numa transação: linhas existentes são atualizadas, uma linha em branco com qualquer valor vira um filho novo, e uma caixa de exclusão marcada remove a linha.

Transparente, sem mágica

  • O FK do pai é implícito — forçado ao pai, nunca é um input; o usuário não escolhe (nem consegue reapontar) o pai errado.
  • Cada linha é escopada ao pai: um filho cujo FK não bate é ignorado, nunca editado por tabela.
  • Colunas de upload/autocomplete ficam no form próprio do filho (não entram no formset compacto).
  • Erros de validação re-renderizam o formset in-place, com mensagem por campo e sem inserir nada.
  • Precisa do AdminModel do filho + can_edit (e can_delete pra excluir). Até 50 linhas por inline.

Dashboard: cards de métricas de negócio (dashboard_cards=)

O dashboard já mostra CPU/RAM/contadores. Para métricas do seu negócio — pedidos hoje, receita vs semana passada, usuários por plano — passe dashboard_cards. Cada card é um MetricCard(label, compute) onde compute é uma função async que recebe a sessão e devolve um de três tipos:

from datetime import date, timedelta

from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from tempest_fastapi_sdk import (
    AdminSite,
    MetricCard,
    MetricPartition,
    MetricTrend,
    MetricValue,
)

from src.db.models import OrderModel
from src.db.repositories import OrderRepository


async def orders_today(session: AsyncSession) -> MetricValue:
    total = await OrderRepository(session).count(filters={"start_in": date.today()})
    return MetricValue(total, unit="pedidos")


async def revenue_between(session: AsyncSession, start: date, end: date) -> float:
    """Soma a receita dos pedidos criados em [start, end)."""
    result = await session.execute(
        select(func.coalesce(func.sum(OrderModel.total), 0)).where(
            func.date(OrderModel.created_at) >= start,
            func.date(OrderModel.created_at) < end,
        )
    )
    return float(result.scalar_one())


async def revenue_trend(session: AsyncSession) -> MetricTrend:
    tomorrow = date.today() + timedelta(days=1)
    this_week = tomorrow - timedelta(days=7)
    last_week = this_week - timedelta(days=7)
    return MetricTrend(
        value=await revenue_between(session, this_week, tomorrow),
        previous=await revenue_between(session, last_week, this_week),
        unit="BRL",
    )


async def users_by_plan(session: AsyncSession) -> MetricPartition:
    return MetricPartition(segments=[("free", 120), ("pro", 30), ("enterprise", 4)])


site = AdminSite(
    title="Shop",
    dashboard_cards=[
        MetricCard("Pedidos hoje", orders_today, help_text="desde 00h"),
        MetricCard("Receita", revenue_trend),
        MetricCard("Usuários por plano", users_by_plan),
    ],
)
  • MetricValue(value, unit=None) — um número grande.
  • MetricTrend(value, previous, unit=None) — número + seta ▲/▼ e a variação percentual vs o período anterior (delta / pct / direction calculados pra você).
  • MetricPartition(segments=[(label, value), ...]) — breakdown com barras proporcionais; total somado automaticamente.

Os cards renderizam no topo do dashboard, computados a cada load. Um card cujo compute levanta é pulado — uma métrica quebrada nunca zera a página — e a falha vai para o log com traceback (logger.exception no logger tempest_fastapi_sdk.admin.router, nomeando o label do card), então o card que sumiu da tela aparece no log.

Import CSV (can_import=True)

O admin já exporta a listagem (CSV/JSON). A contraparte: subir um CSV para criar registros em massa. Habilite com can_import=True:

from tempest_fastapi_sdk import AdminModel

from src.admin import site
from src.db.models import Product


site.register(AdminModel(model=Product, can_import=True))

Aparece um link Import CSV na list view → página com upload. O CSV precisa de uma linha de cabeçalho com os nomes das colunas editáveis; a página mostra quais são. Cada linha seguinte é validada e coagida com as mesmas regras do create (tipos, obrigatórios) e vira um registro.

O resultado é best-effort: linhas válidas são criadas, e um relatório lista as linhas puladas com o erro de cada campo — uma linha ruim nunca aborta as outras.

Opt-in + requer create

can_import é False por padrão e exige can_create (importar é criar em massa). Colunas de upload de arquivo não entram pelo CSV.

RBAC granular (access_policy=)

Por padrão, quem loga no admin (is_admin) faz tudo o que os flags can_* de cada AdminModel permitem. Para dar a um admin acesso só a alguns modelos/ações — um "suporte" que vê pedidos mas não apaga, um "editor" que só mexe em conteúdo — passe um access_policy no make_admin_router:

from fastapi import FastAPI

from tempest_fastapi_sdk import (
    AdminModel,
    AdminPermission,
    UserModelAuthBackend,
    make_admin_router,
)

from src.admin import site
from src.api.dependencies.resources import db
from src.core.settings import settings
from src.db.models import AuditLog, UserModel

app = FastAPI()


def policy(user: UserModel, admin: AdminModel, action: AdminPermission) -> bool:
    if user.role == "superadmin":
        return True
    if user.role == "support":
        return action is AdminPermission.VIEW      # só leitura
    return admin.model is not AuditLog             # editor: tudo menos AuditLog


app.include_router(
    make_admin_router(
        site,
        db=db,
        auth_backend=UserModelAuthBackend(UserModel),
        secret_key=settings.JWT_SECRET,
        access_policy=policy,
    ),
)

A política é consultada (principal, admin, action) em toda ação — VIEW / CREATE / EDIT / DELETE. Negar dá 403; negar VIEW também esconde o modelo do dashboard e da barra lateral, e negar create/edit/delete some com os botões correspondentes. Vale em list, detail, create, edit, delete, bulk (delete → DELETE, resto → EDIT), export, import e autocomplete de FK.

Compõe com os flags can_*

A policy soma aos flags can_create/can_edit/can_delete do AdminModel — os dois precisam liberar. Sem access_policy, nada muda (todo admin logado faz tudo). Pode ser sync ou async.

Widgets de campo por tipo de coluna

O form de create/edit escolhe o widget pelo tipo da coluna, sem configuração: bool → checkbox, Enum → select, int/float/Decimal → number, datetime/date/time → inputs nativos, string longa → textarea, coluna JSON → editor JSON monoespaçado (pretty-print ao abrir, json.loads + validação no submit — JSON inválido vira erro de campo, não uma string salva no lugar). FK vira <select> (ou autocomplete, via autocomplete_fields), e upload_fields vira input de arquivo.

Senha no form (password_fields=)

Um modelo que herda BaseUserModel não podia ser criado pelo painel. O form não mostra hashed_password (ninguém digita um digest bcrypt num <input type="text">), a coluna é NOT NULL, e o insert respondia um 400 com Conflict creating <Model> — mensagem que o operador lê como "e-mail já cadastrado".

password_fields= fecha o caminho: a caixa aceita texto puro e o save grava o hash.

# src/admin.py
from tempest_fastapi_sdk import AdminModel, AdminSite, PasswordPolicy

from src.db.models import LocalUserModel

site = AdminSite(title="Painel")
site.register(
    AdminModel(
        LocalUserModel,
        password_fields=[LocalUserModel.hashed_password],
        password_policy=PasswordPolicy(min_length=12, require_complexity=True),
        list_display=[LocalUserModel.name, LocalUserModel.email],
    ),
)

O que muda, campo a campo:

  • Create: <input type="password" autocomplete="new-password">, obrigatório enquanto a coluna for NOT NULL. O valor passa por check_password_policy e, reprovando, vira erro no campo — não mais um ConflictException. O rótulo perde o prefixo hashed_ (hashed_password → Password): a caixa recebe texto puro, e chamá-la de "Hashed Password" descreveria a coluna, não o que se digita.
  • Edit: a caixa nasce vazia e vazio significa não mexer. Preenchida, rotaciona o hash. O digest nunca pré-popula o input.
  • Hash: usa o set_password do próprio modelo quando existe (é o que BaseUserModel ships, e onde um projeto troca o algoritmo); senão, PasswordUtils.
  • Nunca aparece: fora do list_display (mesmo declarado à mão), fora do detail e fora do export CSV/JSON.

password_fields não combina com can_import=True

Hashear uma coluna de texto puro vinda de arquivo é outra decisão, e o importador postaria linha nenhuma com senha. A combinação levanta ValueError na construção do AdminModel — no boot, não em produção.

A política vem das suas settings

PasswordPolicy.from_settings(settings) lê AUTH_PASSWORD_MIN_LENGTH, AUTH_PASSWORD_MAX_BYTES e AUTH_PASSWORD_REQUIRE_COMPLEXITY do Settings que você já compõe de AuthSettings — assim o painel aplica a mesma regra que o signup. Sem argumento, valem os defaults do AuthSettings.

Coluna que nunca aparece (exclude_fields=)

Nem toda credencial é senha. A assinatura de Web Push guarda endpoint, p256dh e auth — quem tem os três manda push para aquele aparelho —, o usuário guarda um push_token e um totp_secret. readonly_fields não resolve: ele tranca o input e mostra o valor no detail, para qualquer admin.

exclude_fields= tira a coluna do painel inteiro:

# src/admin.py
from tempest_fastapi_sdk import AdminModel, AdminSite

from src.db.models import LocalUserModel, WebPushSubscriptionModel

site = AdminSite(title="Painel")
site.register(
    AdminModel(
        LocalUserModel,
        exclude_fields=[LocalUserModel.totp_secret, LocalUserModel.push_token],
    ),
)
site.register(
    AdminModel(
        WebPushSubscriptionModel,
        exclude_fields=[
            WebPushSubscriptionModel.endpoint,
            WebPushSubscriptionModel.p256dh,
            WebPushSubscriptionModel.auth,
        ],
        can_create=False,
    ),
)

A coluna sai de toda superfície de uma vez: listagem, detail, form de create e edit (um POST que mande o campo à mão é ignorado), import CSV, export CSV/JSON, cabeçalho ordenável (?sort=endpoint é ignorado e vale a ordenação default), a tabela de inline no detail do pai — mesmo que o Inline declare a coluna no próprio list_display —, as linhas de antes/depois da linha do tempo de auditoria, e o rótulo que o <select> de FK de outro model mostra. Todas leem o mesmo conjunto, hidden_field_names(), que é hashed_password + password_fields + exclude_fields.

A configuração que se contradiz falha na construção do AdminModel, no boot, em vez de renderizar o segredo:

  • nome que não é coluna do model;
  • coluna excluída que outra opção também nomeia: list_display, list_filter, search_fields, readonly_fields, upload_fields, autocomplete_fields, password_fields, identity_field, ordering ou um Lens (filters ou order_by);
  • coluna NOT NULL sem default excluída com can_create=True — o form nunca a preencheria, e todo create acabaria em Conflict creating <Model>. É o caso da assinatura de push acima, por isso o can_create=False.

E a tabela de auditoria?

Esconder a linha do tempo não basta: com audit_model=, o add_audited / update_audited gravariam o valor na própria tabela de auditoria, legível por query ou pelo console SQL do painel. Por isso o AdminModel com audit_model= recusa uma coluna excluída que o model não declare em __audit_redact__:

# src/db/models.py
from typing import ClassVar

from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column

from tempest_fastapi_sdk import BaseModel


class WebPushSubscriptionModel(BaseModel):
    """Assinatura de Web Push de um aparelho."""

    __audit_redact__: ClassVar[frozenset[str]] = frozenset(
        {"endpoint", "p256dh", "auth"}
    )

    platform: Mapped[str] = mapped_column(String(16))
    endpoint: Mapped[str] = mapped_column(String(512))
    p256dh: Mapped[str] = mapped_column(String(128))
    auth: Mapped[str] = mapped_column(String(64))

A entrada de auditoria grava "[redacted]" no lugar do valor. No update, o diff é calculado sobre os valores reais antes da troca, então rotacionar a chave ainda aparece — {"before": "[redacted]", "after": "[redacted]"} —, e None continua None, para "não definido" seguir distinguível de "definido". hashed_password é redigido sempre, declarado ou não.

can_import=True continua permitido

Ao contrário de password_fields, não há decisão escondida no import: a coluna excluída simplesmente não é importável, como não é editável.

Fuso do operador (display_timezone=)

Uma coluna TIMESTAMP(timezone=True) guarda instante, e o form do admin renderiza <input type="datetime-local">, que não carrega offset nenhum. Sem dizer em que fuso a caixa é lida, o painel vira um editor de coluna crua: a submissão chega sem tzinfo, e quem decide o que ela significa passa a ser o driver — com asyncpg, o TZ do processo. Num container em UTC o operador em Teresina digita 20:00 e a linha fica com 20:00Z, três horas à frente (medido no consumidor).

Declare o fuso e as três telas passam a concordar:

from tempest_fastapi_sdk import AdminModel

from src.admin import site
from src.db.models import EventModel


site.register(
    AdminModel(
        model=EventModel,
        list_display=[EventModel.name, EventModel.date],
        display_timezone="America/Sao_Paulo",
    ),
)

O que muda, e só isso:

  • Listagem e detail convertem cada datetime para o fuso e mostram o offset junto (2026-06-15 20:00:00-03:00), então a tela nunca é ambígua.
  • Form pré-preenche a caixa com a hora local (2026-06-15T20:00) e escreve Timezone: America/Sao_Paulo — stored as UTC. embaixo, porque o datetime-local não tem onde dizer isso sozinho.
  • Submit lê o valor como hora de parede naquele fuso e grava em UTC. Valor que já chega com offset é respeitado, não sobrescrito.

Converter só na escrita é pior que não converter

A meia-correção passa em todo teste sobre gravar e ainda assim move a linha: o form mostra a hora UTC, o operador salva sem tocar em nada, e o valor exibido é relido como hora local. Cada save de um formulário intocado empurra a linha três horas. É por isso que as duas pontas mudam juntas, e por isso o teste que fixa a regra é um POST real de um form que ninguém editou.

Declare uma vez no AdminSite

O fuso é do painel, não de uma tabela: AdminSite(display_timezone=...) vale para todo model registrado depois, e um AdminModel que declara o seu próprio continua com ele.

O export continua em UTC

CSV e JSON alimentam máquina. Um timestamp que muda de significado entre a tela e o arquivo é pior que um que sempre significa a mesma coisa, então a conversão fica nas telas que uma pessoa lê.

O fuso é validado onde é declarado

display_timezone="America/Sao_Paolo" (com o typo) levanta ValueError na construção do AdminModel, não no request que renderiza o form. Fuso resolvido tarde já custou caro aqui: a string de offset do cron morria dentro do loop do scheduler, calada.

Lenses — visões salvas (lenses=)

Uma lens é um preset nomeado de filtros + ordenação, mostrado como aba acima da listagem. Em vez de o operador reentrar "status=aberto, prioridade>=3, mais antigos primeiro" toda vez, ele clica na aba:

from tempest_fastapi_sdk import AdminModel, Lens

from src.admin import site
from src.db.models import Ticket


site.register(
    AdminModel(
        model=Ticket,
        lenses=[
            Lens("Abertos", filters={"status": "open"}),
            Lens(
                "Urgentes",
                filters={"status": "open", "priority__gte": 3},
                order_by="-created_at",
            ),
        ],
    )
)

A lista ganha as abas All / Abertos / Urgentes. Clicar aplica os filtros da lens (ANDeados com a busca/filtros que o usuário já usar) e a ordenação (order_by, -col = desc, a menos que o usuário clique num cabeçalho). A lens ativa é preservada em paginação, sort e export; "All" volta ao padrão.

Mesmas convenções de filtro

filters usa o mesmo dict do repository (campo__gte, name ILIKE, iterável → IN, …). O slug da aba (?lens=) sai do nome (minúsculo, hífens).

4. Defaults de segurança de sessão

SignedCookieSessionStore usa itsdangerous.TimestampSigner (HMAC-SHA256) para assinar um único cookie:

  • HttpOnly sempre definido.
  • Secure marcado quando cookie_secure=True (padrão; desligue no dev HTTP local).
  • SameSite=Lax ("lax"/"strict"/"none" aceitos).
  • Tempo de vida padrão 8h; cookies expirados ou adulterados são rejeitados silenciosamente.
  • O cookie não basta: toda rota autenticada relê o principal pelo auth_backend a cada request. Conta apagada, desativada ou sem is_admin depois do login é mandada para /admin/login (303) já no request seguinte, sem esperar o cookie expirar.
  • Um token CSRF por sessão é gerado no login e exigido por todo POST de formulário (login, logout, criar, editar, excluir, ações em massa, execução no console SQL, cancelamento de task).
  • secret_key deve ter ao menos 32 bytes — chaves curtas levantam ValueError no momento da construção.

Login em loop? É o Secure do cookie sobre HTTP puro

Se o POST /admin/login responde 303 (parece sucesso), mas o GET /admin/ seguinte redireciona de volta pro login — repetindo pra sempre — o cookie de sessão não está voltando. Causa quase certa: cookie_secure=True enquanto o admin é servido por HTTP puro (sem TLS na frente). O browser recusa gravar um cookie Secure em conexão não-HTTPS, então nenhuma sessão persiste.

# ❌ Atado a DEBUG: em produção DEBUG=false → cookie_secure=True,
#    mas se não houver HTTPS na frente, o login entra em loop.
make_admin_router(..., cookie_secure=not settings.DEBUG)

# ✅ Controle dedicado, independente de DEBUG:
make_admin_router(..., cookie_secure=settings.ADMIN_COOKIE_SECURE)

Correção certa: ponha HTTPS na frente (nginx/Caddy terminando TLS) e deixe cookie_secure=True — o cookie da sessão admin não deve trafegar em claro. Paliativo só quando o admin roda mesmo em HTTP (intranet, MVP): cookie_secure=False, ciente de que a sessão vai sem Secure. Não amarre esse flag ao DEBUG — ligar debug em produção é pior que o problema original.

5. Plugue um backend de auth customizado

AdminAuthBackend é uma ABC genérica no principal — o valor que authenticate devolve e que todo o resto dos métodos consome. Troque o default por LDAP / OAuth / IAM externo subclasseando, e diga no parâmetro qual é o seu principal:

from uuid import UUID

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from tempest_fastapi_sdk import AdminAuthBackend, AdminAuthError, GoogleOAuthClient

from src.core.settings import settings
from src.db.models import AdminModel

my_oauth_client = GoogleOAuthClient(
    client_id=settings.GOOGLE_CLIENT_ID,
    client_secret=settings.GOOGLE_CLIENT_SECRET,
    redirect_uri=settings.GOOGLE_REDIRECT_URI,
)


class OAuthAdminBackend(AdminAuthBackend[AdminModel]):
    """Troca a credencial do formulário do admin por uma identidade OAuth.

    OAuth não tem senha para conferir, então o campo `password` do
    formulário carrega o authorization code que o provedor devolveu no
    redirect. A ABC chama o parâmetro de `password`; o que este backend
    espera ali é o code.
    """

    async def authenticate(
        self,
        session: AsyncSession,
        *,
        identifier: str,
        password: str,
    ) -> AdminModel:
        """Troca o code e aceita só um e-mail de admin conhecido."""
        tokens = await my_oauth_client.exchange_code(password)
        identity = await my_oauth_client.fetch_user(tokens)
        if not identity.email_verified or identity.email != identifier:
            raise AdminAuthError("not an admin")
        result = await session.execute(
            select(AdminModel).where(AdminModel.email == identity.email)
        )
        row = result.scalar_one_or_none()
        if row is None:
            raise AdminAuthError("not an admin")
        return row

    async def load_principal(
        self,
        session: AsyncSession,
        principal_id: str,
    ) -> AdminModel | None:
        """Recarrega a linha de admin a cada request."""
        return await session.get(AdminModel, UUID(principal_id))

    def principal_id(self, principal: AdminModel) -> str:
        """Devolve o identificador que vai serializado no cookie."""
        return str(principal.id)

    def display_name(self, principal: AdminModel) -> str:
        """Devolve o que o cabeçalho do admin mostra."""
        return principal.email

Passe a instância via auth_backend= e o resto do pipeline do admin (sessões, dashboard, list, detail) segue funcionando sem mudanças.

O parâmetro [AdminModel] é o que torna o contrato conferível

Sem ele a classe resolve para AdminAuthBackend[Any] — que é como ela se comportava antes da v0.263.0, e continua válido. O que o parâmetro compra é o erro que aparece quando authenticate devolve um tipo e display_name lê outro. Medido com mypy e com basedpyright: os dois recusam um display_name(self, principal: dict[str, str]) numa classe declarada AdminAuthBackend[LdapEntry] — o mypy citando Liskov por nome (This violates the Liskov substitution principle), o basedpyright com reportIncompatibleMethodOverride.

Subclasse sem parâmetro sob mypy --strict

mypy --strict liga disallow_any_generics, então class MeuBackend(AdminAuthBackend) passa a acusar Missing type arguments for generic type "AdminAuthBackend". O runtime não mudou — é só o type-checker. Escreva o seu principal (AdminAuthBackend[MinhaLinha]) ou, para manter exatamente o comportamento anterior, AdminAuthBackend[Any]. O pyproject.toml que o tempest new escreve tem strict = true.

6. Painel de tasks — o que está agendado e o que rodou

TaskPanelService liga o admin ao que os workers já produzem: a agenda declarada no broker e as execuções persistidas por um JobStore.

from fastapi import FastAPI

from tempest_fastapi_sdk import (
    AdminSite,
    AsyncDatabaseManager,
    UserModelAuthBackend,
    make_admin_router,
)
from tempest_fastapi_sdk.admin import TaskPanelService
from tempest_fastapi_sdk.tasks import JobStore, TaskQueue

from src.core.settings import settings
from src.db.models import JobModel, UserModel

app: FastAPI = FastAPI()
db: AsyncDatabaseManager = AsyncDatabaseManager(settings.DATABASE_URL)
tq: TaskQueue = TaskQueue.rabbitmq(settings.RABBITMQ_URL)
jobs: JobStore[JobModel] = JobStore(db, model=JobModel)

app.include_router(
    make_admin_router(
        AdminSite(title="Ops"),
        db=db,
        auth_backend=UserModelAuthBackend(UserModel),
        secret_key=settings.ADMIN_SECRET_KEY,
        tasks=TaskPanelService(queue=tq, job_store=jobs),
    )
)

Isso monta três rotas e um item "Tasks" na barra lateral:

Rota O que faz
GET /admin/tasks execuções recentes (filtráveis por status) + agenda declarada
GET /admin/tasks/{job_id} uma execução: progresso, estágio, tentativas, erro
POST /admin/tasks/{job_id}/cancel pede parada, quando o status ainda permite

O painel não mostra a fila do broker

Ele mostra o que está persistido (linhas de JobStore) e o que está declarado (registro do broker). Profundidade de fila não aparece porque a TaskIQ não expõe — e uma tela que insinuasse isso seria pior que tela nenhuma.

Qualquer uma das metades serve

Só queue= mostra a agenda; só job_store= mostra as execuções. A seção sem fonte não é renderizada, em vez de aparecer vazia e sugerir que não há nada para ver. Passar nenhuma das duas levanta ValueError na construção.

A coluna Schedule carrega cada gatilho declarado

Uma task pode declarar vários gatilhos, e nem todo gatilho é cron: a TaskIQ aceita cron, interval e time (disparo único) na mesma lista. A coluna mostra uma linha por gatilho, com o fuso ao lado da expressão quando a task declarou um:

Declarado Aparece como
@tq.cron(daily(hour=2), cron_offset=CronOffset.BRASILIA) 0 2 * * * -03:00
@tq.cron(daily(hour=2)) 0 2 * * *
@tq.interval(timedelta(seconds=30)) every 30s
schedule=[{"time": quando}] once at 2026-01-01 12:00:00+00:00
@tq.task on demand

Mostrar o fuso não é enfeite. Uma expressão cron parece completa sem ele, então nada convida o leitor a conferir — e 0 2 * * * com cron_offset=CronOffset.BRASILIA dispara às 05:00 UTC. Quem abre o painel para saber quando o backup roda erraria por três horas, num lugar onde a única outra fonte é o código.

O painel mostra o que foi declarado, não o default da TaskIQ: expressão sem fuso aparece sem fuso, não como (UTC).

Renderizando a agenda por conta própria

ScheduledTask.triggers é a tupla de TaskTrigger, na ordem do registro, e TaskTrigger.cron_offset_label formata o fuso ("-03:00" para offset fixo, a chave IANA para zona nomeada). Os campos planos — cron, interval_seconds, cron_offset, run_at — resumem o primeiro gatilho de cada tipo, para quem só precisa de um deles. Chave de schedule que o SDK ainda não modela fica em TaskTrigger.extra em vez de ser descartada.

Por que não existe coluna \"próxima execução\"

O pycron, que chega junto com a TaskIQ, expõe is_now e has_been — e nenhum next(). Calcular a próxima execução exigiria varrer minuto a minuto a cada render (até ~44 mil iterações para um cron mensal) ou adicionar dependência nova por uma coluna. O painel mostra a expressão — com o fuso ao lado, porque sem ele a expressão não é legível.

Para a lista com paginação, filtro e export que o admin já sabe fazer, registre também o model de jobs — simétrico ao painel de dead-letter:

from tempest_fastapi_sdk import AdminSite
from tempest_fastapi_sdk.tasks import make_job_admin_model

from src.db.models import JobModel

site: AdminSite = AdminSite(title="Ops")
site.register(make_job_admin_model(JobModel))

7. Customizar a aparência — AdminTheme

O CSS do admin é todo dirigido por CSS custom properties em :root. Em vez de forkar a folha de estilo, você passa um AdminTheme com parâmetros tipados e documentados — cores, logo, favicon, fonte, raio, rodapé, modo escuro — e a SDK injeta um bloco <style> no <head> (depois do admin.css, então ele vence).

# src/admin/site.py
from tempest_fastapi_sdk import AdminSite, AdminTheme

theme: AdminTheme = AdminTheme(
    accent="#7c3aed",                       # cor primária (links, botões, item ativo)
    accent_hover="#6d28d9",                 # tom de hover do accent
    header_bg="#1e1b4b",                    # fundo do header/sidebar
    radius="10px",                          # raio de botões, inputs, cards, tabelas
    font_family="'Inter', system-ui, sans-serif",
    logo_url="/admin/static/logo.svg",      # imagem no header (no lugar do texto)
    favicon_url="/admin/static/favicon.ico",
    footer_text="Servus | 2026",
    dark_mode=False,                         # superfícies de conteúdo escuras
)

site: AdminSite = AdminSite(title="Servus Admin", brand="Servus", theme=theme)

AdminTheme() sem argumentos é um no-op: reproduz a aparência padrão. Você só define o que quer mudar.

A regra de ouro

Cada campo do AdminTheme mapeia para uma variável CSS de :root (ou para um pedaço de chrome, como o logo). É tudo tipado — o autocomplete do editor lista as opções e o mypy valida — e nenhuma string precisa ser um nome de classe CSS ou seletor.

Campo Tipo Padrão Efeito
accent str "#2563eb" Cor primária: links, botões, item ativo da sidebar
accent_hover str "#1d4ed8" Tom de hover/ativo do accent
danger str "#b91c1c" Ações destrutivas e mensagens de erro
header_bg str "#0f172a" Fundo do header
sidebar_bg str | None None Fundo da sidebar (cai pra header_bg)
page_bg str | None None Fundo do conteúdo (padrão do modo)
radius str "6px" Raio de botões, inputs, cards, tabelas
font_family str | None None font-family do painel inteiro
logo_url str | None None Imagem no header em vez do texto
logo_alt str "Logo" alt da imagem do logo
favicon_url str | None None Favicon da aba
footer_text str "Powered by tempest-fastapi-sdk" Texto do rodapé
dark_mode bool False Superfícies de conteúdo escuras
custom_css_url str | None None Folha de estilo extra, linkada por último

Modo escuro

dark_mode=True troca as superfícies de conteúdo (fundo da página, texto, linhas da tabela, inputs, bordas) para uma paleta escura. O header/sidebar já são escuros, então não mudam; accent e as outras cores continuam valendo. Um page_bg explícito vence o modo escuro.

Escape hatch para o resto

Para o que os campos não cobrem, aponte custom_css_url para a sua própria folha de estilo. Ela é linkada depois do tema, então sobrescreve tudo — inclusive o AdminTheme.

Valores são do desenvolvedor, não do usuário final

Os caracteres < > { } " são rejeitados em qualquer campo de texto (ValueError na construção), porque quebrariam o <style> injetado ou um atributo HTML. Nunca derive valores de AdminTheme de entrada de usuário final.

Recap: instancie AdminTheme com os campos que quer mudar, passe via AdminSite(theme=...), e a aparência muda em todas as páginas (login, dashboard, list, detail, forms) sem tocar em CSS. Para customização total, custom_css_url.

Recap

  • AdminSite + AdminModel transformam os seus models numa interface de CRUD sem você escrever template: a declaração é a tela.
  • @admin_action põe operação de domínio na lista, e o retorno AdminActionResult é o que o operador lê de volta.
  • audit_model= renderiza a linha do tempo de quem mudou o quê no próprio detail, e inlines= traz os filhos 1-N para a mesma página.
  • autocomplete_fields= troca o <select> que não escala por busca HTMX — necessário no instante em que a FK tem milhares de linhas.
  • dashboard_cards= são métricas de negócio, não de sistema; lenses= são as visões salvas que o operador usaria de novo amanhã.
  • access_policy= é RBAC por (principal, ação, model): sem ele, quem entra no admin pode tudo.
  • AdminTheme cobre a aparência por campo tipado; can_import=True abre o import CSV, que grava cada linha válida na hora e devolve o relatório das que pulou — não há pré-visualização.
  • exclude_fields= tira a credencial que não é senha de toda superfície do painel; readonly_fields só tranca a escrita.
  • display_timezone= é o que transforma o campo de datetime em widget: a tela lê e escreve no fuso do operador, a coluna continua em UTC.