Painel admin¶
UI de gerenciamento no estilo Django montada sob /admin. Operadores entram com uma linha de usuário do banco (sem store de senha de admin separado) e navegam por todo modelo registrado pelo navegador, então a porta do banco pode ficar fechada em redes privadas. A Fase 1 entrega views somente leitura; criar/editar/apagar chegam na 0.14.0 e ações inline + em lote na 0.15.0.
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",
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.
3. Monte o router¶
# src/api/app.py
from fastapi import FastAPI
from tempest_fastapi_sdk import (
AsyncDatabaseManager,
UserModelAuthBackend,
make_admin_router,
)
from src.admin.site import site
from src.core.settings import settings
from src.db.models import UserModel
db = AsyncDatabaseManager(settings.DATABASE_URL)
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
)
)
make_admin_router monta:
GET /admin/login,POST /admin/login,POST /admin/logout— fluxo de auth.GET /admin/— dashboard listando todo admin registrado.GET /admin/m/{slug}/— list view com paginação + busca em texto livre (?q=) + filtros por campo (?filter_<field>=value).GET /admin/m/{slug}/{identity}— detail view somente leitura.GET /admin/static/{path}— assets CSS/HTMX embutidos.
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 (apenas
logoutna Fase 1). secret_keydeve ter ao menos 32 bytes — chaves curtas levantamValueErrorno momento da construção.
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
class OAuthAdminBackend(AdminAuthBackend):
async def authenticate(
self,
session: AsyncSession,
*,
identifier: str,
password: str,
) -> Any:
principal = await my_oauth_client.authenticate(identifier, password)
if not principal.has_role("admin"):
raise AdminAuthError("not an admin")
return principal
async def load_principal(
self,
session: AsyncSession,
principal_id: str,
) -> Any | None:
return await my_oauth_client.get_user(principal_id)
def principal_id(self, principal: Any) -> str:
return principal.sub
def display_name(self, principal: Any) -> str:
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.