Files
DECNET/decnet/web/router/topology/api_personas.py
anti fc1f0914b7 refactor(topology): introduce TopologyRepository protocol with DTO return types
Replace repo: BaseRepository with a structural TopologyRepository protocol
in persistence.py and allocator.py. All read methods now return typed DTOs
(TopologySummary, LANRow, DeckyRow, EdgeRow) instead of raw dicts, eliminating
silent field-shape regressions across the topology subsystem.

TopologySummary gains email_personas and language_default so api_personas.py
can continue reading those fields via attribute access. hydrate() converts
DTOs to dicts before passing to _backfill_decky_configs, keeping the mutable
working-state function dict-based at its boundary. All production callers
(router handlers, mutator, CLI, heartbeat) migrated from dict/get access to
attribute access. 134 tests pass.
2026-04-30 23:51:41 -04:00

132 lines
4.5 KiB
Python

"""GET/PUT ``/topologies/{id}/personas`` — per-topology email persona pool.
The global pool (``decnet/web/router/emailgen/api_personas.py``) drives
non-MazeNET fleet/SWARM-shard mail deckies. MazeNET topology mail
deckies use ``Topology.email_personas`` instead — one JSON-serialized
list per topology, parsed by the emailgen scheduler each tick.
This endpoint is the API surface behind the dashboard's per-topology
"Personas" editor. Reads accept admin or viewer; writes are admin-only.
Concurrency: last-write-wins. The list is operator-curated and small
(typically <20 entries); no need for optimistic versioning here.
"""
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from decnet.logging import get_logger
from decnet.realism.personas import EmailPersona, parse_personas
from decnet.telemetry import traced as _traced
from decnet.web.dependencies import repo, require_admin, require_viewer
router = APIRouter()
log = get_logger("api.topology.personas")
def _serialize(personas: list[EmailPersona]) -> list[dict[str, Any]]:
return [p.model_dump(exclude_none=False) for p in personas]
@router.get(
"/{topology_id}/personas",
tags=["MazeNET Topologies"],
responses={
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology not found"},
},
)
@_traced("api.topology.list_personas")
async def list_topology_personas(
topology_id: str,
_viewer: dict = Depends(require_viewer),
) -> dict[str, Any]:
"""Return the topology's persona list and its language default.
``language_default`` is included so the editor can show which
language unset entries fall back to — same fallback the scheduler
applies when building prompts.
"""
topo = await repo.get_topology(topology_id)
if topo is None:
raise HTTPException(status_code=404, detail="Topology not found")
language_default = topo.language_default or "en"
personas = parse_personas(
topo.email_personas, language_default=language_default,
)
return {
"topology_id": topology_id,
"topology_name": topo.name,
"language_default": language_default,
"personas": _serialize(personas),
}
@router.put(
"/{topology_id}/personas",
tags=["MazeNET Topologies"],
responses={
400: {"description": "Invalid persona payload"},
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology not found"},
},
)
@_traced("api.topology.replace_personas")
async def replace_topology_personas(
topology_id: str,
body: dict[str, Any],
user: dict = Depends(require_admin),
) -> dict[str, Any]:
"""Replace the topology's persona list.
Body shape: ``{"personas": [<EmailPersona>, ...]}``.
Drop-invalid semantics mirror the global-pool endpoint: bad entries
are skipped with a warning rather than failing the whole request, but
a wholly invalid payload returns 400 so a schema mistake doesn't
silently wipe the list.
"""
raw = body.get("personas")
if not isinstance(raw, list):
raise HTTPException(
status_code=400, detail="body.personas must be a list",
)
topo = await repo.get_topology(topology_id)
if topo is None:
raise HTTPException(status_code=404, detail="Topology not found")
language_default = topo.language_default or "en"
parsed = parse_personas(raw, language_default=language_default)
if raw and not parsed:
raise HTTPException(
status_code=400,
detail=(
"All persona entries failed validation. Required fields: "
"name, email (user@host.tld), role, tone, mannerisms."
),
)
serialized = _serialize(parsed)
payload = json.dumps(serialized, ensure_ascii=False)
updated = await repo.set_topology_email_personas(topology_id, payload)
if not updated:
# Race: row vanished between the get and the update.
raise HTTPException(status_code=404, detail="Topology not found")
log.info(
"api.topology.replace_personas user=%s topology=%s wrote=%d",
user.get("username", user.get("uuid")), topology_id, len(parsed),
)
return {
"topology_id": topology_id,
"topology_name": topo.name,
"language_default": language_default,
"personas": serialized,
}