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]:
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 viaMetricsUtils). Painel ligado por default, omitido sem o extra[metrics], desligável commake_admin_router(show_metrics=False).GET /admin/logs— logs da aplicação (quandoshow_logs=True): lê os arquivos JSON estruturados escritos peloconfigure_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 viamake_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 (quandocan_create).GET /admin/m/{slug}/{identity}— detail view com botões Edit/Delete.GET/POST /admin/m/{slug}/{identity}/edit— editar registro (quandocan_edit).POST /admin/m/{slug}/{identity}/delete— excluir registro (quandocan_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-dataautomaticamente quando háupload_fields. - Create: arquivo obrigatório só se a coluna for
NOT NULLe 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 oupload_storage(ouUploadUtils) 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 viaextra=, 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
AdminModeldo filho +can_edit(ecan_deletepra 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/directioncalculados pra você).MetricPartition(segments=[(label, value), ...])— breakdown com barras proporcionais;totalsomado 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:
HttpOnlysempre definido.Securemarcado quandocookie_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_keydeve ter ao menos 32 bytes — chaves curtas levantamValueErrorno 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+AdminModeltransformam os seus models numa interface de CRUD sem você escrever template: a declaração é a tela.@admin_actionpõe operação de domínio na lista, e o retornoAdminActionResulté o que o operador lê de volta.audit_model=renderiza a linha do tempo de quem mudou o quê no próprio detail, einlines=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.AdminThemecobre a aparência por campo tipado;can_import=Trueabre o import CSV com pré-visualização antes de gravar.