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},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user