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}— 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 oprefix: comprefix="/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-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(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 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/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 — 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 forNOT NULL. O valor passa porcheck_password_policye, reprovando, vira erro no campo — não mais umConflictException. O rótulo perde o prefixohashed_(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_passworddo próprio modelo quando existe (é o queBaseUserModelships, 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,orderingou umLens(filtersouorder_by); - coluna
NOT NULLsem default excluída comcan_create=True— o form nunca a preencheria, e todo create acabaria emConflict creating <Model>. É o caso da assinatura de push acima, por isso ocan_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
datetimepara 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 escreveTimezone: America/Sao_Paulo — stored as UTC.embaixo, porque odatetime-localnã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:
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. - O cookie não basta: toda rota autenticada relê o principal pelo
auth_backenda cada request. Conta apagada, desativada ou semis_admindepois 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_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 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+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, 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_fieldssó 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.