Files
DECNET/decnet/web/router/topology/api_personas.py
anti f2b3393669 chore: relicense to AGPL-3.0-or-later and add SPDX headers
Replaces LICENSE (GPLv3 -> AGPLv3) and prepends
`SPDX-License-Identifier: AGPL-3.0-or-later` to every source file
across decnet/, decnet_web/, tests/, scripts/, and tools/.

Rationale: closes the GPLv3 ASP loophole so any party operating a
modified DECNET as a network service must offer their modified
source. Personal copyright (Samuel Paschuan) + inbound=outbound
contributions make a future unilateral relicense infeasible.

- LICENSE: full AGPL-3.0 text (gnu.org/licenses/agpl-3.0.txt)
- COPYRIGHT: project copyright notice
- tools/add_spdx_headers.py: idempotent header injector
  (shebang- and PEP 263-aware)

Touches 1565 source files (.py, .ts, .tsx, .js, .jsx, .css, .sh).
No behavior change; comments only.
2026-05-22 21:04:16 -04:00

133 lines
4.5 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
"""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,
}