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/logslogs 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/exportexport 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.jsonexporta 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}/bulkações em massa (delete / activate / deactivate + suas ações customizadas) nas linhas selecionadas.
  • GET/POST /admin/m/{slug}/newcriar registro (quando can_create).
  • GET /admin/m/{slug}/{identity} — detail view com botões Edit/Delete.
  • GET/POST /admin/m/{slug}/{identity}/editeditar registro (quando can_edit).
  • POST /admin/m/{slug}/{identity}/deleteexcluir registro (quando can_delete).
  • GET /admin/static/{path} — assets CSS/HTMX embutidos.

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.

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.name}! 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(name="Ana", 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.ext.asyncio import AsyncSession

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

from src.db.repositories import OrderRepository

today = date.today()
this_week = today - timedelta(days=7)
last_week = this_week - timedelta(days=7)


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


async def revenue_trend(session: AsyncSession) -> MetricTrend:
    return MetricTrend(
        value=await this_week(session), previous=await last_week(session), 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="últimas 24h"),
        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.

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.

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.
  • 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).
  • 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, então troque o default por LDAP / OAuth / IAM externo subclasseando:

from typing import Any

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):
    """Trade the admin form's credential for an OAuth identity.

    OAuth has no password to check, so the form's `password` field carries
    the authorization code the provider redirected back with. The ABC names
    the parameter `password`; what this backend expects there is the code.
    """

    async def authenticate(
        self,
        session: AsyncSession,
        *,
        identifier: str,
        password: str,
    ) -> Any:
        """Exchange the code, then accept only an allowed admin e-mail."""
        tokens = await my_oauth_client.exchange_code(password)
        principal = await my_oauth_client.fetch_user(tokens)
        if not principal.email_verified or principal.email != identifier:
            raise AdminAuthError("not an admin")
        return principal

    async def load_principal(
        self,
        session: AsyncSession,
        principal_id: str,
    ) -> Any | None:
        """Reload the admin row the subject maps to, on every request."""
        return await session.get(AdminModel, principal_id)

    def principal_id(self, principal: Any) -> str:
        """Return the provider's stable subject identifier."""
        return principal.subject

    def display_name(self, principal: Any) -> str:
        """Return what the admin header shows."""
        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.

6. 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 com pré-visualização antes de gravar.