Merge pull request 'Discord admin page + hide admin-only bot pool from operators (#213 items 2, 4)' (#219) from feat/213-discord-admin into main
Reviewed-on: #219
This commit was merged in pull request #219.
This commit is contained in:
@@ -107,6 +107,9 @@ class StreamStart(BaseModel):
|
||||
|
||||
@router.post("/streams")
|
||||
async def start_stream(body: StreamStart, _: dict = Depends(require_service_key_or_admin)):
|
||||
# The bot always sends snowflakes; the web admin page types them in.
|
||||
if not (str(body.guild_id).isdigit() and str(body.channel_id).isdigit()):
|
||||
raise HTTPException(400, "guild_id and channel_id must be Discord ids (digits only).")
|
||||
org_id = await _org_or_403(body.guild_id)
|
||||
system = await fstore.doc_get_cached("systems", body.system_id)
|
||||
if not system or system.get("org_id") not in (None, org_id):
|
||||
@@ -144,18 +147,103 @@ async def stop_guild(guild_id: str, body: GuildStop, _: dict = Depends(require_s
|
||||
|
||||
class GuildLink(BaseModel):
|
||||
org_id: str
|
||||
# Display label only (the admin page); Discord never reads it.
|
||||
guild_name: Optional[str] = None
|
||||
|
||||
|
||||
@router.put("/guilds/{guild_id}/link")
|
||||
async def link_guild(guild_id: str, body: GuildLink, decoded: dict = Depends(require_admin_token)):
|
||||
if not guild_id.isdigit():
|
||||
raise HTTPException(400, "guild_id must be a Discord server id (digits only).")
|
||||
org = await fstore.doc_get("organizations", body.org_id)
|
||||
if not org:
|
||||
raise HTTPException(404, "Organization not found.")
|
||||
await fstore.doc_set(streams.GUILDS, guild_id, {
|
||||
previous_org = await streams.resolve_guild_org(guild_id)
|
||||
doc = {
|
||||
"guild_id": guild_id, "org_id": body.org_id,
|
||||
"linked_by": decoded.get("uid"), "linked_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
return {"ok": True, "guild_id": guild_id, "org_id": body.org_id}
|
||||
}
|
||||
if body.guild_name and body.guild_name.strip():
|
||||
doc["guild_name"] = body.guild_name.strip()[:100]
|
||||
await fstore.doc_set(streams.GUILDS, guild_id, doc)
|
||||
# Live channels in this server were placed on the previous org's nodes;
|
||||
# left running, the reconciler would keep re-placing them there, so one
|
||||
# org's equipment would keep serving a server that now belongs to another.
|
||||
stopped: list[str] = []
|
||||
if previous_org and previous_org != body.org_id:
|
||||
stopped = await streams.stop_guild(guild_id, "server linked to another organization")
|
||||
return {"ok": True, "guild_id": guild_id, "org_id": body.org_id, "stopped": stopped}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin view — the web Discord page (server-26#213 item 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/admin/overview")
|
||||
async def admin_overview(_: dict = Depends(require_admin_token)):
|
||||
"""Every Discord server DRB knows about, every radio channel (desired vs
|
||||
actual) and what the page needs to act on them. Platform admin only.
|
||||
|
||||
A server is "known" when it is linked (``discord_guilds``) or when it has
|
||||
ever had a radio channel — an unlinked server that ran ``/join`` acts for
|
||||
``DISCORD_UNLINKED_GUILD_ORG_ID`` and should be visible so it can be linked.
|
||||
Radio bots come from ``GET /tokens``; no token string is returned here.
|
||||
Discord user ids (``created_by_discord_id``) and the linking admin's uid
|
||||
are deliberately left out.
|
||||
"""
|
||||
links = {str(g.get("guild_id")): g for g in await fstore.collection_list(streams.GUILDS)
|
||||
if g.get("guild_id")}
|
||||
all_streams = await fstore.collection_list(streams.STREAMS)
|
||||
orgs = {o.get("org_id"): o for o in await fstore.collection_list("organizations") if o.get("org_id")}
|
||||
nodes = {n.get("node_id"): n for n in await fstore.collection_list("nodes") if n.get("node_id")}
|
||||
tokens = {t.get("token_id"): t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
|
||||
fallback = settings.discord_unlinked_guild_org_id or None
|
||||
|
||||
guilds: dict[str, dict] = {}
|
||||
for gid, link in links.items():
|
||||
guilds[gid] = {"guild_id": gid, "guild_name": link.get("guild_name"), "linked": bool(link.get("org_id")),
|
||||
"org_id": link.get("org_id"), "linked_at": link.get("linked_at"),
|
||||
# Explicitly unlinked servers get no fallback org (mirrors
|
||||
# streams.resolve_guild_org once server-26#218 lands).
|
||||
"_no_fallback": bool(link.get("unlinked_at"))}
|
||||
rows = []
|
||||
for s in all_streams:
|
||||
gid = str(s.get("guild_id") or leases.guild_of(s.get("stream_id") or ""))
|
||||
g = guilds.setdefault(gid, {"guild_id": gid, "guild_name": None, "linked": False,
|
||||
"org_id": None, "linked_at": None})
|
||||
if s.get("guild_name") and not g.get("guild_name"):
|
||||
g["guild_name"] = s["guild_name"]
|
||||
node = nodes.get(s.get("node_id")) or {}
|
||||
tok = tokens.get(s.get("token_id")) or {}
|
||||
rows.append({**_public_stream(s), "guild_id": gid, "org_id": s.get("org_id"), "guild_name": s.get("guild_name"),
|
||||
"created_at": s.get("created_at"), "node_name": node.get("name"),
|
||||
"node_status": node.get("status"), "token_name": tok.get("name")})
|
||||
for g in guilds.values():
|
||||
no_fallback = g.pop("_no_fallback", False)
|
||||
acting = g["org_id"] if g["linked"] else (None if no_fallback else fallback)
|
||||
g["acting_org_id"] = acting
|
||||
g["acting_org_name"] = (orgs.get(acting) or {}).get("name") if acting else None
|
||||
g["live_streams"] = sum(1 for r in rows if r.get("guild_id") == g["guild_id"] and r.get("desired") == "on")
|
||||
|
||||
rows.sort(key=lambda r: (0 if r.get("desired") == "on" else 1, r.get("guild_name") or r.get("guild_id") or "",
|
||||
r.get("channel_name") or r.get("channel_id") or ""))
|
||||
systems = await fstore.collection_list("systems")
|
||||
# Names for every node a stream or a radio-bot lease points at.
|
||||
referenced = {r.get("node_id") for r in rows} | {(t.get("lease") or {}).get("node_id") for t in tokens.values()}
|
||||
node_names = {nid: nodes[nid].get("name") or nid for nid in referenced if nid in nodes}
|
||||
return {
|
||||
"guilds": sorted(guilds.values(), key=lambda g: (not g["linked"], g.get("guild_name") or g["guild_id"])),
|
||||
"streams": rows,
|
||||
"orgs": sorted(({"org_id": oid, "name": o.get("name") or oid} for oid, o in orgs.items()),
|
||||
key=lambda o: o["name"].lower()),
|
||||
"node_names": node_names,
|
||||
"systems": [{"system_id": s.get("system_id"), "name": s.get("name"), "org_id": s.get("org_id")}
|
||||
for s in systems if s.get("system_id")],
|
||||
"limits": {"guild_max_streams": settings.discord_guild_max_streams,
|
||||
"node_max_streams": settings.discord_node_max_streams,
|
||||
"unlinked_guild_org_id": fallback,
|
||||
"unlinked_guild_org_name": (orgs.get(fallback) or {}).get("name") if fallback else None},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user