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
Build & Deploy / Build & push images (push) Successful in 5m1s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 5s
Build & Deploy / Deploy to VM (push) Successful in 7s
Build & Deploy / Report a failed deploy (push) Skipped

Reviewed-on: #219
This commit was merged in pull request #219.
This commit is contained in:
2026-10-04 22:05:36 -04:00
13 changed files with 1510 additions and 173 deletions
+91 -3
View File
@@ -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},
}
# ---------------------------------------------------------------------------
+129
View File
@@ -0,0 +1,129 @@
"""
Web Discord admin page backend (server-26#213 item 2): GET /discord/admin/overview,
plus the id validation the page relies on for PUT /discord/guilds/{id}/link and
POST /discord/streams.
"""
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app.config import settings
from app.internal.auth import require_admin_token, require_service_key_or_admin
from app.main import app
from tests.fake_firestore import FakeFirestore
client = TestClient(app)
ADMIN = {"uid": "admin-1", "role": "admin"}
@pytest.fixture
def fs(monkeypatch):
fake = FakeFirestore.install(monkeypatch)
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "founding")
yield fake
app.dependency_overrides.clear()
def _seed(fake):
fake.put("organizations", "founding", {"org_id": "founding", "name": "Founding"})
fake.put("organizations", "org-b", {"org_id": "org-b", "name": "Bravo Fire"})
fake.put("discord_guilds", "111", {"guild_id": "111", "org_id": "org-b", "linked_by": "uid-x",
"linked_at": "2026-10-01T00:00:00+00:00", "guild_name": "Bravo Server"})
fake.put("nodes", "n1", {"node_id": "n1", "name": "Node One", "status": "online", "org_id": "org-b"})
fake.put("bot_tokens", "t1", {"token_id": "t1", "name": "Radio 1", "token": "secret-t1"})
fake.put("systems", "s1", {"system_id": "s1", "name": "County P25", "org_id": "org-b"})
fake.put("discord_streams", "111:900", {
"stream_id": "111:900", "guild_id": "111", "channel_id": "900", "channel_name": "Dispatch",
"org_id": "org-b", "system_id": "s1", "mode": "persistent", "desired": "on", "node_id": "n1",
"token_id": "t1", "actual": {"state": "connected"}, "created_by_discord_id": "4242",
})
# A server that ran /join before anyone linked it — acts for the fallback org.
fake.put("discord_streams", "222:901", {
"stream_id": "222:901", "guild_id": "222", "channel_id": "901", "guild_name": "Unlinked Server",
"org_id": "founding", "system_id": "s1", "mode": "adhoc", "desired": "off",
"actual": {"state": "stopping"},
})
def test_overview_requires_platform_admin(fs):
def deny():
raise HTTPException(403, "Admin access required")
app.dependency_overrides[require_admin_token] = deny
assert client.get("/discord/admin/overview").status_code == 403
def test_overview_lists_linked_and_seen_guilds_streams_and_limits(fs):
_seed(fs)
app.dependency_overrides[require_admin_token] = lambda: ADMIN
body = client.get("/discord/admin/overview").json()
guilds = {g["guild_id"]: g for g in body["guilds"]}
assert guilds["111"]["linked"] is True and guilds["111"]["acting_org_name"] == "Bravo Fire"
assert guilds["111"]["live_streams"] == 1 and guilds["111"]["guild_name"] == "Bravo Server"
# unlinked but seen: shown, acting for the fallback org, name taken from its stream
assert guilds["222"]["linked"] is False and guilds["222"]["acting_org_id"] == "founding"
assert guilds["222"]["guild_name"] == "Unlinked Server" and guilds["222"]["live_streams"] == 0
assert [g["guild_id"] for g in body["guilds"]] == ["111", "222"] # linked first
s = body["streams"][0]
assert s["stream_id"] == "111:900" and s["node_name"] == "Node One" and s["token_name"] == "Radio 1"
assert s["actual"]["state"] == "connected"
assert body["streams"][1]["desired"] == "off" # live channels first
text = repr(body)
assert "secret-t1" not in text # no token strings
assert "4242" not in text # no Discord user ids
assert "uid-x" not in text # no linking-admin uid
assert body["limits"]["unlinked_guild_org_name"] == "Founding"
assert {o["org_id"] for o in body["orgs"]} == {"founding", "org-b"}
assert body["node_names"] == {"n1": "Node One"}
assert body["systems"] == [{"system_id": "s1", "name": "County P25", "org_id": "org-b"}]
def test_link_rejects_non_snowflake_and_stores_label(fs):
_seed(fs)
app.dependency_overrides[require_admin_token] = lambda: ADMIN
assert client.put("/discord/guilds/abc/link", json={"org_id": "org-b"}).status_code == 400
r = client.put("/discord/guilds/333/link", json={"org_id": "org-b", "guild_name": " Charlie "})
assert r.status_code == 200
assert fs.get("discord_guilds", "333")["guild_name"] == "Charlie"
assert client.put("/discord/guilds/333/link", json={"org_id": "nope"}).status_code == 404
def test_start_stream_rejects_non_snowflake_ids(fs):
app.dependency_overrides[require_service_key_or_admin] = lambda: ADMIN
r = client.post("/discord/streams", json={"guild_id": "111", "channel_id": "general", "system_id": "s1"})
assert r.status_code == 400
def test_relinking_a_server_to_another_org_stops_its_live_channels(fs, monkeypatch):
from app.internal import discord_streams as streams
calls = []
async def _stop_guild(guild_id, reason, channel_id=None):
calls.append((guild_id, reason))
return [f"{guild_id}:900"]
monkeypatch.setattr(streams, "stop_guild", _stop_guild)
_seed(fs)
app.dependency_overrides[require_admin_token] = lambda: ADMIN
# same org again: nothing stopped
assert client.put("/discord/guilds/111/link", json={"org_id": "org-b"}).json()["stopped"] == []
# unlinked server (acting for the fallback org) linked to that same org: nothing stopped
assert client.put("/discord/guilds/222/link", json={"org_id": "founding"}).json()["stopped"] == []
assert calls == []
# moved to a different org: its live channels are stopped
r = client.put("/discord/guilds/111/link", json={"org_id": "founding"}).json()
assert r["stopped"] == ["111:900"] and calls == [("111", "server linked to another organization")]
def test_explicitly_unlinked_server_gets_no_fallback_org(fs):
# server-26#218 unlinks by clearing org_id and stamping unlinked_at;
# resolve_guild_org then refuses the fallback, so the page must not offer it.
_seed(fs)
fs.put("discord_guilds", "444", {"guild_id": "444", "org_id": None, "unlinked_at": "2026-10-02T00:00:00+00:00"})
app.dependency_overrides[require_admin_token] = lambda: ADMIN
guilds = {g["guild_id"]: g for g in client.get("/discord/admin/overview").json()["guilds"]}
assert guilds["444"]["linked"] is False and guilds["444"]["acting_org_id"] is None
assert "_no_fallback" not in guilds["444"]
assert guilds["222"]["acting_org_id"] == "founding" # never-linked server keeps the fallback