household_people joins a roster family to the accounts of its parents; a
household can have two, a person can rarely be on two. PUT
/roster/households/{id}/people sets it (admin and above). GET /family
(account:self) returns the signed-in person's own households with scouts
and this year's checklist, and nothing else - the parent view of the
roster, read only. Inactive households drop off it.
tests/smoke_admin.py 141 -> 147.
965 lines
43 KiB
Python
965 lines
43 KiB
Python
"""
|
|
admin_api.py - read API over the internal record store.
|
|
|
|
This is the seam the future scout admin panel plugs into. The panel talks HTTP
|
|
to these endpoints; it never opens the SQLite file directly. That keeps the
|
|
panel deployable anywhere (separate container, separate host) and keeps this
|
|
app the only writer to its own database.
|
|
|
|
Leads are read-only, and that is deliberate. The old PATCH route set
|
|
status/assigned_to/notes on a lead - columns that were removed because they
|
|
were a guess at an outreach process nobody has designed. When that process
|
|
exists it gets its own table and its own write routes; until then there is
|
|
nothing on a lead to mutate.
|
|
|
|
Announcements ARE writable, and are the deliberate exception to that. The
|
|
read-only rule exists because a lead has nothing mutable on it. An
|
|
announcement is defined by being mutable and expiring - it is posted, it
|
|
shows, it comes down. Writing it is the entire feature. The caps that keep
|
|
the banner from becoming a mess live in store.py, not here, so the panel and
|
|
any future client inherit them rather than reimplementing them.
|
|
|
|
Auth, two ways, session first.
|
|
|
|
A signed-in person holding the required capability is allowed. Otherwise the
|
|
X-Admin-Token header must match ADMIN_TOKEN, which is retained as BREAK GLASS:
|
|
if the identity layer is broken there has to be a way in that does not depend
|
|
on the identity layer.
|
|
|
|
Still FAILS CLOSED. With no session and no ADMIN_TOKEN set, every route returns
|
|
503. These endpoints expose parent names, emails and phone numbers for minors'
|
|
families, so an unconfigured deployment must not serve them. Adding sessions
|
|
widened who may read; it did not soften what happens when nothing is
|
|
configured.
|
|
|
|
Capability, not role, decides. Lead routes need leads:read, announcement routes
|
|
need announcements:write, and both are answered by identity.can() against the
|
|
one CAPS dictionary - never by a role comparison here.
|
|
"""
|
|
|
|
import hmac
|
|
import html as _html
|
|
import inspect
|
|
import ipaddress
|
|
import os
|
|
import re
|
|
|
|
from fastapi import APIRouter, Body, Header, HTTPException, Query, Request
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
import auth
|
|
import calendar_write
|
|
import identity
|
|
import store
|
|
|
|
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "").strip()
|
|
|
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
|
|
|
# /api/docs sits beside /api/admin, not under it: it describes the admin API
|
|
# and is gated like it, but a docs URL under the admin prefix would be one
|
|
# more path the LAN-only NPM block has to reason about.
|
|
docs_router = APIRouter(prefix="/api", tags=["docs"])
|
|
|
|
|
|
# What counts as "inside" for the LAN rule. Facts about the network, so
|
|
# they live in code; the CHOICE of whether keys are LAN-only is a setting.
|
|
# The docker range is here because NPM, the routines and any sibling
|
|
# container reach this app from arrstack_arr_net, and a container on that
|
|
# network is already inside.
|
|
LAN_NETS = [ipaddress.ip_network(n) for n in
|
|
("10.0.0.0/24", "10.0.1.0/24", "127.0.0.0/8", "172.16.0.0/12")]
|
|
|
|
|
|
def client_is_lan(request):
|
|
"""True if the caller's address is on the LAN. Uses the first
|
|
X-Forwarded-For hop, which NPM sets; an unparseable or absent address is
|
|
NOT lan - the rule fails closed."""
|
|
try:
|
|
ip = ipaddress.ip_address(auth._client_ip(request) or "")
|
|
except ValueError:
|
|
return False
|
|
return any(ip in n for n in LAN_NETS)
|
|
|
|
|
|
def _log(request, kind, detail):
|
|
"""One line in the action log for a write, attributed to whoever the
|
|
session or key resolved to; the break-glass token logs as itself."""
|
|
person = _person(request)
|
|
identity.log_event(kind, person_id=person["id"] if person else None,
|
|
actor_id=person["id"] if person else None,
|
|
email=person["email"] if person else "admin-token",
|
|
detail=detail, ip=auth._client_ip(request))
|
|
|
|
|
|
def _diff(before, after, fields):
|
|
"""'town: "X" -> "Y"; meets: "-" -> "Mondays"' for the fields that changed."""
|
|
out = []
|
|
for f in fields:
|
|
a, b = (before or {}).get(f), (after or {}).get(f)
|
|
if a != b:
|
|
out.append('%s: %r -> %r' % (f, a if a not in (None, "") else "-", b if b not in (None, "") else "-"))
|
|
return "; ".join(out) or "no change"
|
|
|
|
|
|
def _person(request):
|
|
"""Who is calling: a session first, then an API key, then nobody.
|
|
|
|
Keys are honoured ONLY here, on the admin API. The rest of the site
|
|
(documents, account) is for people at a screen and reads sessions alone,
|
|
so a scoped key never widens into a browser identity."""
|
|
p = auth.current_person(request)
|
|
if p:
|
|
return p
|
|
h = request.headers.get("authorization", "")
|
|
if h.lower().startswith("bearer "):
|
|
return identity.api_key_person(h[7:].strip())
|
|
return None
|
|
|
|
|
|
def _auth(request, token, capability, unit_id=None):
|
|
"""Authorise, and return a label naming who acted, for created_by.
|
|
|
|
Order matters: session first, so a normal signed-in leader never depends on
|
|
the shared token, and the token stays a fallback rather than the everyday
|
|
path.
|
|
|
|
unit_id scopes a membership capability to one unit: a pack leader holds
|
|
unit:write_own, but only within the pack. Global roles pass a scoped check
|
|
for every unit, and the break-glass token reaches everything - it exists
|
|
for when identity is broken, so it cannot depend on identity's scoping.
|
|
"""
|
|
person = _person(request)
|
|
if person:
|
|
if person.get("key_scopes") is not None and not client_is_lan(request) \
|
|
and identity.get_setting("api_keys_from") == "lan":
|
|
raise HTTPException(403, "API keys may only be used from the LAN right now "
|
|
"(site setting api_keys_from)")
|
|
if not identity.can(person, capability, unit_id):
|
|
raise HTTPException(403, "your account does not have %s" % capability
|
|
+ (" for this unit" if unit_id else "")
|
|
+ (" (key %s is scoped narrower)" % person["key_prefix"]
|
|
if person.get("key_scopes") is not None else ""))
|
|
return person["email"]
|
|
if not ADMIN_TOKEN:
|
|
raise HTTPException(503, "admin API disabled: sign in, or set ADMIN_TOKEN")
|
|
if not token or not hmac.compare_digest(token, ADMIN_TOKEN):
|
|
raise HTTPException(401, "sign in, or send a valid X-Admin-Token")
|
|
# The shared token is break glass for an operator at the console. It is
|
|
# never usable from outside, and that is not a setting.
|
|
if not client_is_lan(request):
|
|
raise HTTPException(403, "the admin token is LAN-only")
|
|
return "admin-token"
|
|
|
|
|
|
@router.get("/summary")
|
|
def get_summary(request: Request, x_admin_token: str = Header(None)):
|
|
_auth(request, x_admin_token, "leads:read")
|
|
return store.summary()
|
|
|
|
|
|
@router.get("/leads")
|
|
def get_leads(request: Request, since: str = None, q: str = None,
|
|
limit: int = Query(100, ge=1, le=500), offset: int = 0,
|
|
x_admin_token: str = Header(None)):
|
|
_auth(request, x_admin_token, "leads:read")
|
|
return {"leads": store.list_leads(since=since, q=q, limit=limit, offset=offset)}
|
|
|
|
|
|
@router.get("/leads/{record_id}")
|
|
def get_one(request: Request, record_id: str, x_admin_token: str = Header(None)):
|
|
_auth(request, x_admin_token, "leads:read")
|
|
rec = store.get_lead(record_id)
|
|
if not rec:
|
|
raise HTTPException(404, "no such lead")
|
|
return rec
|
|
|
|
|
|
@router.post("/leads/{record_id}/claim")
|
|
def claim_lead(request: Request, record_id: str, x_admin_token: str = Header(None)):
|
|
"""Take a lead so it is not sitting unclaimed. Outreach v1 is exactly
|
|
this: who picked it up and when. No outcomes. 409 if someone else has
|
|
it - taking over is release, then claim, never a silent overwrite."""
|
|
_auth(request, x_admin_token, "leads:read")
|
|
person = _person(request)
|
|
if not person:
|
|
raise HTTPException(403, "claiming needs a signed-in person")
|
|
try:
|
|
cur = store.claim_lead(record_id, person["id"], person["email"])
|
|
except store.ClaimRejected as e:
|
|
raise _reject(e)
|
|
if not cur:
|
|
raise HTTPException(404, "no such lead")
|
|
_log(request, "lead.claimed", record_id)
|
|
return {"claim": {"email": cur["email"], "claimed_at": cur["claimed_at"]}}
|
|
|
|
|
|
@router.post("/leads/{record_id}/release")
|
|
def release_lead(request: Request, record_id: str, x_admin_token: str = Header(None)):
|
|
"""Let a lead go. The holder may; so may an owner (people:manage),
|
|
which is how a lead gets reassigned when someone steps back."""
|
|
_auth(request, x_admin_token, "leads:read")
|
|
person = _person(request)
|
|
if not person:
|
|
raise HTTPException(403, "releasing needs a signed-in person")
|
|
try:
|
|
cur = store.release_lead(record_id, person["id"], person["email"],
|
|
can_release_any=identity.can(person, "people:manage"))
|
|
except store.ClaimRejected as e:
|
|
raise _reject(e)
|
|
if not cur:
|
|
raise HTTPException(404, "no such lead")
|
|
_log(request, "lead.released", "%s (was %s)" % (record_id, cur["email"]))
|
|
return {"claim": None}
|
|
|
|
|
|
@router.get("/mirrors/failed")
|
|
def failed(request: Request, target: str = "google_sheet", x_admin_token: str = Header(None)):
|
|
_auth(request, x_admin_token, "leads:read")
|
|
return {"target": target, "leads": store.failed_mirror_records(target)}
|
|
|
|
|
|
@router.post("/mirrors/retry")
|
|
def retry(request: Request, target: str = "google_sheet", x_admin_token: str = Header(None)):
|
|
"""Replay leads whose copy to an external target failed. Idempotent-ish:
|
|
a lead already marked ok is never retried."""
|
|
_auth(request, x_admin_token, "leads:read")
|
|
if target != "google_sheet":
|
|
raise HTTPException(422, "only google_sheet retry is implemented")
|
|
import app as main_app
|
|
done, failed_ids = 0, []
|
|
for rec in store.failed_mirror_records(target, limit=200):
|
|
try:
|
|
main_app.sheet_append(rec["payload"])
|
|
store.set_mirror(rec["id"], target, True)
|
|
done += 1
|
|
except Exception as e:
|
|
store.set_mirror(rec["id"], target, False, e)
|
|
failed_ids.append(rec["id"])
|
|
return {"retried_ok": done, "still_failing": failed_ids}
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Announcements - the one writable object here. See the module docstring.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def _reject(e):
|
|
"""Turn a store guardrail into its HTTP answer, carrying the detail so the
|
|
caller is told what to do rather than just refused."""
|
|
payload = {"error": e.detail}
|
|
payload.update(e.extra)
|
|
return HTTPException(e.status, payload)
|
|
|
|
|
|
@router.get("/announcements")
|
|
def list_announcements(request: Request, include_expired: bool = False,
|
|
limit: int = Query(100, ge=1, le=500),
|
|
x_admin_token: str = Header(None)):
|
|
"""Every announcement with its computed state: live, scheduled, expired,
|
|
revoked, or over_cap. State is returned rather than left to be inferred
|
|
from what the homepage happens to render."""
|
|
_auth(request, x_admin_token, "announcements:write")
|
|
return {"announcements": store.list_announcements(
|
|
include_expired=include_expired, limit=limit)}
|
|
|
|
|
|
@router.post("/announcements", status_code=201)
|
|
def create_announcement(request: Request, payload: dict = Body(...), x_admin_token: str = Header(None)):
|
|
"""Post a notice. ends_at is required.
|
|
|
|
422 if the message is over the character cap or the window is invalid.
|
|
409 if the live cap is already reached, listing what is up so you can
|
|
decide what to revoke."""
|
|
_actor = _auth(request, x_admin_token, "announcements:write")
|
|
try:
|
|
rec = store.create_announcement(
|
|
message=payload.get("message"),
|
|
ends_at=payload.get("ends_at"),
|
|
starts_at=payload.get("starts_at"),
|
|
level=payload.get("level", "info"),
|
|
link_url=payload.get("link_url"),
|
|
link_text=payload.get("link_text"),
|
|
# From the session, never from the body. A caller must not be able
|
|
# to attribute a public notice to somebody else. Stored as the
|
|
# email rather than the uuid: the column is display-facing, people
|
|
# are never hard deleted, and a uuid in a banner audit trail helps
|
|
# nobody read it.
|
|
created_by=_actor,
|
|
)
|
|
except store.AnnouncementRejected as e:
|
|
raise _reject(e)
|
|
_log(request, "announcement.created", "%s %r" % (rec["id"], (rec.get("message") or "")[:60]))
|
|
return rec
|
|
|
|
|
|
@router.delete("/announcements/{announcement_id}")
|
|
def revoke_announcement(request: Request, announcement_id: str, x_admin_token: str = Header(None)):
|
|
"""Take one down early. Sets revoked_at; never deletes the row."""
|
|
_auth(request, x_admin_token, "announcements:write")
|
|
if not store.get_announcement(announcement_id):
|
|
raise HTTPException(404, "no such announcement")
|
|
if not store.revoke_announcement(announcement_id):
|
|
raise HTTPException(409, "already revoked")
|
|
rec = store.get_announcement(announcement_id)
|
|
_log(request, "announcement.revoked", "%s %r" % (announcement_id, (rec.get("message") or "")[:60]))
|
|
return rec
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Nearby units - the /find-a-unit directory, and the first writable directory
|
|
# data. The verified_at and deactivate-not-delete rules live in store.py so
|
|
# any future client inherits them.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
@router.get("/nearby")
|
|
def list_nearby(request: Request, include_inactive: bool = False,
|
|
x_admin_token: str = Header(None)):
|
|
"""The editing view, so deactivated rows are reachable. nearby:write
|
|
rather than a read capability: the public page IS the read surface, and
|
|
this list exists only to be edited."""
|
|
_auth(request, x_admin_token, "nearby:write")
|
|
return {"nearby_units": store.list_nearby(include_inactive=include_inactive)}
|
|
|
|
|
|
@router.post("/nearby", status_code=201)
|
|
def create_nearby(request: Request, payload: dict = Body(...),
|
|
x_admin_token: str = Header(None)):
|
|
_auth(request, x_admin_token, "nearby:write")
|
|
try:
|
|
rec = store.create_nearby(payload)
|
|
except store.NearbyRejected as e:
|
|
raise _reject(e)
|
|
_log(request, "nearby.created", "%s %s %s (%s)" % (rec["id"], rec.get("unit_type"), rec.get("unit_number"), rec.get("town")))
|
|
return rec
|
|
|
|
|
|
@router.patch("/nearby/{nearby_id}")
|
|
def update_nearby(request: Request, nearby_id: str, payload: dict = Body(...),
|
|
x_admin_token: str = Header(None)):
|
|
"""Partial update. Saving bumps verified_at to today unless the payload
|
|
carries an explicit date - see store.py for why saving is verifying."""
|
|
_auth(request, x_admin_token, "nearby:write")
|
|
before = store.get_nearby(nearby_id)
|
|
try:
|
|
rec = store.update_nearby(nearby_id, payload)
|
|
except store.NearbyRejected as e:
|
|
raise _reject(e)
|
|
if not rec:
|
|
raise HTTPException(404, "no such nearby unit")
|
|
_log(request, "nearby.updated", "%s %s %s: %s" % (nearby_id, rec.get("unit_type"), rec.get("unit_number"),
|
|
_diff(before, rec, [k for k in store.NEARBY_FIELDS if k != "verified_at"])))
|
|
return rec
|
|
|
|
|
|
@router.delete("/nearby/{nearby_id}")
|
|
def deactivate_nearby(request: Request, nearby_id: str, x_admin_token: str = Header(None)):
|
|
"""Take a unit off the page. Sets active=0; never deletes the row."""
|
|
_auth(request, x_admin_token, "nearby:write")
|
|
if not store.get_nearby(nearby_id):
|
|
raise HTTPException(404, "no such nearby unit")
|
|
if not store.deactivate_nearby(nearby_id):
|
|
raise HTTPException(409, "already inactive")
|
|
rec = store.get_nearby(nearby_id)
|
|
_log(request, "nearby.deactivated", "%s %s %s" % (nearby_id, rec.get("unit_type"), rec.get("unit_number")))
|
|
return rec
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Our own units - meeting time and place only. unit:write_own is checked
|
|
# against the unit in the URL, so a pack leader edits the pack and not the
|
|
# troop; admins hold it globally and reach both.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
@router.get("/units")
|
|
def list_units(request: Request, x_admin_token: str = Header(None)):
|
|
"""The units the caller may edit, which is what the screen this feeds
|
|
shows. A pack leader gets the pack; a global role or the break-glass
|
|
token gets everything. The answer IS the scope - no second filter for
|
|
the console to get wrong."""
|
|
_auth(request, x_admin_token, "unit:write_own")
|
|
units = identity.list_units(include_inactive=True)
|
|
person = auth.current_person(request)
|
|
if person:
|
|
units = [u for u in units if identity.can(person, "unit:write_own", u["id"])]
|
|
return {"units": units}
|
|
|
|
|
|
@router.patch("/units/{slug_or_id}")
|
|
def update_unit(request: Request, slug_or_id: str, payload: dict = Body(...),
|
|
x_admin_token: str = Header(None)):
|
|
unit = identity.get_unit(slug_or_id)
|
|
# Scope to the unit when it exists; an unknown slug still goes through
|
|
# _auth first, so probing paths answers 401/403 before it answers 404.
|
|
_auth(request, x_admin_token, "unit:write_own",
|
|
unit_id=unit["id"] if unit else None)
|
|
try:
|
|
rec = identity.update_unit_meets(slug_or_id, payload)
|
|
except identity.IdentityError as e:
|
|
raise HTTPException(e.status, e.detail)
|
|
_log(request, "unit.updated", "%s: %s" % (rec.get("slug"), _diff(unit, rec, list(identity.UNIT_MEETS_FIELDS))))
|
|
return rec
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Site settings - the typed key registry lives in identity.SETTINGS_KEYS;
|
|
# unknown keys are rejected there, not silently stored.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
@router.get("/settings")
|
|
def list_settings(request: Request, x_admin_token: str = Header(None)):
|
|
_auth(request, x_admin_token, "settings:write")
|
|
return {"settings": identity.all_settings()}
|
|
|
|
|
|
@router.put("/settings/{key}")
|
|
def put_setting(request: Request, key: str, payload: dict = Body(...),
|
|
x_admin_token: str = Header(None)):
|
|
"""Set one value, or clear it back to the code default with value: null."""
|
|
actor = _auth(request, x_admin_token, "settings:write")
|
|
before = identity.get_setting(key) if key in identity.SETTINGS_KEYS else None
|
|
try:
|
|
rec = identity.set_setting(key, payload.get("value"), actor=actor)
|
|
except identity.IdentityError as e:
|
|
raise HTTPException(e.status, e.detail)
|
|
after = identity.get_setting(key)
|
|
_log(request, "setting.updated", "%s: %r -> %r%s" % (key, before, after,
|
|
" (cleared to default)" if payload.get("value") is None else ""))
|
|
return rec
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# API keys (P3). Own keys only - apikeys:own. A key cannot reach these routes
|
|
# (apikeys:own is unscopable), and the break-glass token has no person to
|
|
# own a key, so both fall out naturally rather than by special case.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def _key_owner(request, token):
|
|
_auth(request, token, "apikeys:own")
|
|
person = _person(request)
|
|
if not person:
|
|
raise HTTPException(403, "keys belong to a signed-in person; the admin token cannot own one")
|
|
return person
|
|
|
|
|
|
@router.get("/keys")
|
|
def list_keys(request: Request, x_admin_token: str = Header(None)):
|
|
"""Your keys, newest first, with state (active / expired / revoked) and
|
|
the scopes you may put on a new one. Key secrets are never returned."""
|
|
person = _key_owner(request, x_admin_token)
|
|
return {"keys": identity.list_api_keys(person["id"]),
|
|
"scopable": identity.scopable_caps(person),
|
|
"max_days": identity.KEY_MAX_DAYS}
|
|
|
|
|
|
@router.post("/keys", status_code=201)
|
|
def create_key(request: Request, payload: dict = Body(...), x_admin_token: str = Header(None)):
|
|
"""Mint a key. Body: label, scopes (list), expires_days (optional, 1-365).
|
|
The response carries `key` ONCE; it is not stored and cannot be shown again.
|
|
Send it as `Authorization: Bearer gls73_...` to /api/admin routes."""
|
|
person = _key_owner(request, x_admin_token)
|
|
try:
|
|
full, row = identity.mint_api_key(person, payload.get("label"), payload.get("scopes"),
|
|
payload.get("expires_days"))
|
|
except identity.IdentityError as e:
|
|
raise HTTPException(e.status, e.detail)
|
|
row["key"] = full
|
|
return row
|
|
|
|
|
|
@router.delete("/keys/{key_id}")
|
|
def revoke_key(request: Request, key_id: str, x_admin_token: str = Header(None)):
|
|
"""Revoke one of your keys. The row stays. A key that is not yours is
|
|
404, never 403 - an id is not confirmed to exist."""
|
|
person = _key_owner(request, x_admin_token)
|
|
try:
|
|
row = identity.revoke_api_key(person, key_id)
|
|
except identity.IdentityError as e:
|
|
raise HTTPException(e.status, e.detail)
|
|
if not row:
|
|
raise HTTPException(404, "no such key")
|
|
return row
|
|
|
|
|
|
@router.get("/whoami")
|
|
def whoami(request: Request, x_admin_token: str = Header(None)):
|
|
"""Who the API thinks you are and what you can do - the first thing to
|
|
call with a new key."""
|
|
person = _person(request)
|
|
if person:
|
|
if person.get("key_scopes") is not None and not client_is_lan(request) \
|
|
and identity.get_setting("api_keys_from") == "lan":
|
|
raise HTTPException(403, "API keys may only be used from the LAN right now "
|
|
"(site setting api_keys_from)")
|
|
return {"email": person["email"], "global_role": person.get("global_role"),
|
|
"capabilities": person["capabilities"], "lan": client_is_lan(request),
|
|
"via": ("key " + person["key_prefix"]) if person.get("key_scopes") is not None else "session"}
|
|
if not ADMIN_TOKEN:
|
|
raise HTTPException(503, "admin API disabled: sign in, or set ADMIN_TOKEN")
|
|
if x_admin_token and hmac.compare_digest(x_admin_token, ADMIN_TOKEN):
|
|
if not client_is_lan(request):
|
|
raise HTTPException(403, "the admin token is LAN-only")
|
|
return {"email": None, "via": "admin-token", "capabilities": ["*"], "lan": True}
|
|
raise HTTPException(401, "sign in, send a Bearer key, or a valid X-Admin-Token")
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# /api/docs - generated from this router, never hand-written. Path, methods
|
|
# and docstring come from FastAPI's route objects; the capability is read
|
|
# out of each handler's own _auth() call, so it cannot drift from the check.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
_CAP_RE = re.compile(r"""_(?:auth|people_actor)\([^)]*?["']([a-z_]+:[a-z_]+)["']""")
|
|
|
|
|
|
def route_capability(endpoint):
|
|
try:
|
|
src = inspect.getsource(endpoint)
|
|
except (OSError, TypeError):
|
|
return None
|
|
m = _CAP_RE.search(src)
|
|
if m:
|
|
return m.group(1)
|
|
if "_key_owner(" in src:
|
|
return "apikeys:own"
|
|
if "_roster_actor(" in src:
|
|
return "roster:write"
|
|
return None
|
|
|
|
|
|
def describe_routes():
|
|
"""The registry, as data. Also what the smoke test checks."""
|
|
out = []
|
|
for r in router.routes:
|
|
methods = sorted(m for m in getattr(r, "methods", []) or [] if m not in ("HEAD", "OPTIONS"))
|
|
if not methods:
|
|
continue
|
|
params = [p.name for p in inspect.signature(r.endpoint).parameters.values()
|
|
if p.name not in ("request", "x_admin_token", "payload")]
|
|
out.append({"path": r.path, "methods": methods,
|
|
"capability": route_capability(r.endpoint),
|
|
"params": params,
|
|
"doc": inspect.getdoc(r.endpoint) or ""})
|
|
return sorted(out, key=lambda d: (d["path"], d["methods"]))
|
|
|
|
|
|
@docs_router.get("/docs", response_class=HTMLResponse)
|
|
def api_docs(request: Request, x_admin_token: str = Header(None)):
|
|
"""This page. Leader and above."""
|
|
_auth(request, x_admin_token, "api:docs")
|
|
person = _person(request)
|
|
e = _html.escape
|
|
rows = "".join(
|
|
"<tr><td><code>%s</code></td><td><code>%s</code></td><td><code>%s</code></td>"
|
|
"<td>%s</td><td>%s</td></tr>"
|
|
% (e(" ".join(d["methods"])), e(d["path"]), e(d["capability"] or "-"),
|
|
e(", ".join(d["params"])) or "-", e(d["doc"]).replace("\n", "<br>"))
|
|
for d in describe_routes())
|
|
mine = ""
|
|
if person:
|
|
mine = ("<p>You are <code>%s</code>%s with: <code>%s</code>.</p>"
|
|
% (e(person["email"]),
|
|
" via key <code>%s</code>" % e(person["key_prefix"]) if person.get("key_scopes") is not None else "",
|
|
e(", ".join(person["capabilities"]))))
|
|
body = ("<!doctype html><html><head><meta charset=utf-8><meta name=robots content=noindex>"
|
|
"<meta name=viewport content=\"width=device-width,initial-scale=1\">"
|
|
"<title>greenlanescouts73.org admin API</title><style>"
|
|
"body{font:15px/1.5 system-ui,sans-serif;margin:0;padding:24px;color:#1c2430;background:#f4f5f7}"
|
|
"table{border-collapse:collapse;background:#fff;width:100%%}th,td{text-align:left;vertical-align:top;"
|
|
"padding:8px 10px;border-top:1px solid #eef0f3;font-size:14px}th{font-size:12.5px;color:#5b6472}"
|
|
"code{font-size:13px}h1{font-size:20px}.n{max-width:900px}</style></head><body><div class=n>"
|
|
"<h1>greenlanescouts73.org admin API</h1>"
|
|
"<p>Generated from the route registry on every request; there is no hand-written copy to drift.</p>"
|
|
"<p><b>Auth</b>, tried in this order: the site session cookie; "
|
|
"<code>Authorization: Bearer gls73_...</code> (an API key, scoped to the capabilities its owner chose - "
|
|
"mint one at <a href=\"/leaders/keys\">/leaders/keys</a>); <code>X-Admin-Token</code> (break glass, "
|
|
"operators only). A signed-in caller without the capability gets 403; an anonymous one gets 401. "
|
|
"Bodies are JSON. Start with <code>GET /api/admin/whoami</code>.</p>%s"
|
|
"<table><tr><th>Method</th><th>Path</th><th>Needs</th><th>Query / path params</th><th>Notes</th></tr>%s</table>"
|
|
"</div></body></html>" % (mine, rows))
|
|
return HTMLResponse(body)
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Calendar write-back (P4). The site becomes a second writer to Radicale,
|
|
# owning only UIDs ending calendar_write.UID_SUFFIX. Reads come from the
|
|
# scout-calendar feed (fetched fresh here, not from the page cache), so the
|
|
# list is the same rows the public site renders plus uid and ownership.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def _feed_rows():
|
|
import app as main_app
|
|
try:
|
|
rows = main_app._fetch_feed()
|
|
except Exception as e:
|
|
raise HTTPException(502, "calendar feed unreachable: %s" % e)
|
|
for r in rows:
|
|
r["mine"] = calendar_write.owns(r.get("uid")) and not r.get("recurring")
|
|
return rows
|
|
|
|
|
|
@router.get("/calendar")
|
|
def list_calendar(request: Request, x_admin_token: str = Header(None)):
|
|
"""Every event the public calendar shows, newest first is NOT the order:
|
|
the feed's own date order. `mine` marks rows the site created and may
|
|
edit or delete; everything else is read-only here and edited in a
|
|
CalDAV client. `configured` says whether writes are possible at all."""
|
|
_auth(request, x_admin_token, "calendar:write")
|
|
return {"events": _feed_rows(), "configured": calendar_write.configured(),
|
|
"uid_suffix": calendar_write.UID_SUFFIX}
|
|
|
|
|
|
@router.post("/calendar", status_code=201)
|
|
def create_event(request: Request, payload: dict = Body(...), x_admin_token: str = Header(None)):
|
|
"""Add an event. Body: title, date (YYYY-MM-DD), unit (pack|troop|both),
|
|
optional end, time (HH:MM 24h), end_time, location, badge, description.
|
|
No time = all-day. A timed one-day event with no end_time lasts 90 min."""
|
|
_auth(request, x_admin_token, "calendar:write")
|
|
try:
|
|
ev = calendar_write.clean(payload)
|
|
uid = calendar_write.new_uid()
|
|
calendar_write.put_event(uid, ev)
|
|
except calendar_write.CalendarRejected as e:
|
|
raise HTTPException(e.status, e.detail)
|
|
_log(request, "calendar.created", "%s %s %s" % (uid, ev["date"].isoformat(), ev["title"]))
|
|
_bust_site_cache()
|
|
return {"uid": uid, "event": _serial(ev)}
|
|
|
|
|
|
@router.put("/calendar/{uid}")
|
|
def replace_event(request: Request, uid: str, payload: dict = Body(...), x_admin_token: str = Header(None)):
|
|
"""Replace one site-owned event in full (same body as POST). A UID the
|
|
site does not own is 403 before anything is sent to the store."""
|
|
_auth(request, x_admin_token, "calendar:write")
|
|
if not calendar_write.owns(uid):
|
|
raise HTTPException(403, "the site only manages its own events")
|
|
try:
|
|
ev = calendar_write.clean(payload)
|
|
calendar_write.put_event(uid, ev)
|
|
except calendar_write.CalendarRejected as e:
|
|
raise HTTPException(e.status, e.detail)
|
|
_log(request, "calendar.updated", "%s %s %s" % (uid, ev["date"].isoformat(), ev["title"]))
|
|
_bust_site_cache()
|
|
return {"uid": uid, "event": _serial(ev)}
|
|
|
|
|
|
@router.delete("/calendar/{uid}")
|
|
def delete_event(request: Request, uid: str, x_admin_token: str = Header(None)):
|
|
"""Remove one site-owned event from the store. Unlike everything else on
|
|
this API this really deletes: the calendar's history is Radicale's git
|
|
log, not a revoked_at column."""
|
|
_auth(request, x_admin_token, "calendar:write")
|
|
if not calendar_write.owns(uid):
|
|
raise HTTPException(403, "the site only manages its own events")
|
|
try:
|
|
status = calendar_write.delete_event(uid)
|
|
except calendar_write.CalendarRejected as e:
|
|
raise HTTPException(e.status, e.detail)
|
|
if status == 404:
|
|
raise HTTPException(404, "no such event in the store")
|
|
_log(request, "calendar.deleted", uid)
|
|
_bust_site_cache()
|
|
return {"uid": uid, "deleted": True}
|
|
|
|
|
|
def _serial(ev):
|
|
out = dict(ev)
|
|
out["date"] = ev["date"].isoformat()
|
|
out["end"] = ev["end"].isoformat() if ev["end"] else None
|
|
return out
|
|
|
|
|
|
def _bust_site_cache():
|
|
"""The public pages cache the feed for five minutes; a leader who just
|
|
posted an event should not wait that long to see it. The feed itself
|
|
refreshes within a minute."""
|
|
try:
|
|
import app as main_app
|
|
main_app._feed_state["fetched"] = 0.0
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# History - the action log, read side. Admin and above: it carries emails
|
|
# and IPs from logins alongside the content writes.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
@router.get("/history")
|
|
def get_history(request: Request, limit: int = Query(200, ge=1, le=500), kind: str = None,
|
|
before: str = None, x_admin_token: str = Header(None)):
|
|
"""Newest first. `kind` is a prefix filter (calendar., nearby., login.).
|
|
`before` is an ISO stamp for paging. Every write on this API since
|
|
2026-09-04 lands here with who did it and a one-line diff."""
|
|
_auth(request, x_admin_token, "history:read")
|
|
return {"events": identity.list_events(limit=limit, kind_prefix=kind, before=before)}
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# People. Invite, roles, disable/enable, password reset links. The rules
|
|
# (never zero owners, never disable yourself, grant only what you hold) live
|
|
# in identity.py; these routes only translate to HTTP. A session or key is
|
|
# required: the break-glass token has no person to act as.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def _people_actor(request, token, capability):
|
|
_auth(request, token, capability)
|
|
person = _person(request)
|
|
if not person:
|
|
raise HTTPException(403, "people management needs a signed-in person; the admin token cannot act here")
|
|
return person
|
|
|
|
|
|
@router.get("/people")
|
|
def list_people(request: Request, x_admin_token: str = Header(None)):
|
|
"""Everyone with an account, their roles and memberships, active
|
|
sessions and keys; plus open invites; plus what YOU may grant."""
|
|
actor = _people_actor(request, x_admin_token, "people:invite_leader")
|
|
return {"people": identity.list_people(), "invites": identity.list_open_invites(),
|
|
"units": identity.list_units(include_inactive=True),
|
|
"grantable": identity.grantable_roles(actor), "me": actor["id"]}
|
|
|
|
|
|
@router.post("/people/invite", status_code=201)
|
|
def invite(request: Request, payload: dict = Body(...), x_admin_token: str = Header(None)):
|
|
"""Mint an invite. Body: email, global_role (optional), units
|
|
([{unit_id, role, title}]). Returns the invite URL ONCE; there is no
|
|
outbound mail yet, so hand it over yourself. Reissuing for the same
|
|
address revokes the earlier link. 14-day expiry."""
|
|
actor = _people_actor(request, x_admin_token, "people:invite_leader")
|
|
try:
|
|
row, url = identity.invite_person(actor, payload.get("email"), payload.get("global_role") or None,
|
|
payload.get("units") or [])
|
|
except identity.IdentityError as e:
|
|
raise HTTPException(e.status, e.detail)
|
|
row.pop("token_hash", None)
|
|
row["url"] = url
|
|
return row
|
|
|
|
|
|
@router.delete("/people/invite/{invite_id}")
|
|
def revoke_invite(request: Request, invite_id: str, x_admin_token: str = Header(None)):
|
|
actor = _people_actor(request, x_admin_token, "people:invite_leader")
|
|
row = identity.revoke_invite(actor, invite_id)
|
|
if not row:
|
|
raise HTTPException(404, "no such open invite")
|
|
row.pop("token_hash", None)
|
|
return row
|
|
|
|
|
|
@router.put("/people/{person_id}/roles")
|
|
def put_roles(request: Request, person_id: str, payload: dict = Body(...), x_admin_token: str = Header(None)):
|
|
"""Replace a person's global role and unit memberships. Body:
|
|
global_role (owner|admin|null), units ([{unit_id, role, title}], the full
|
|
list). Owner only. Refuses to leave zero owners."""
|
|
actor = _people_actor(request, x_admin_token, "people:manage")
|
|
try:
|
|
rec = identity.set_roles(actor, person_id, payload.get("global_role") or None, payload.get("units") or [])
|
|
except identity.IdentityError as e:
|
|
raise HTTPException(e.status, e.detail)
|
|
if not rec:
|
|
raise HTTPException(404, "no such person")
|
|
return rec
|
|
|
|
|
|
@router.post("/people/{person_id}/disable")
|
|
def disable(request: Request, person_id: str, payload: dict = Body(None), x_admin_token: str = Header(None)):
|
|
"""Disable a person now: sessions end, keys stop, documents close. The
|
|
row stays. Owner only; not yourself; not the last owner."""
|
|
actor = _people_actor(request, x_admin_token, "people:manage")
|
|
try:
|
|
rec = identity.disable_person(actor, person_id, (payload or {}).get("reason"))
|
|
except identity.IdentityError as e:
|
|
raise HTTPException(e.status, e.detail)
|
|
if not rec:
|
|
raise HTTPException(404, "no such person")
|
|
return rec
|
|
|
|
|
|
@router.post("/people/{person_id}/enable")
|
|
def enable(request: Request, person_id: str, x_admin_token: str = Header(None)):
|
|
actor = _people_actor(request, x_admin_token, "people:manage")
|
|
try:
|
|
rec = identity.enable_person(actor, person_id)
|
|
except identity.IdentityError as e:
|
|
raise HTTPException(e.status, e.detail)
|
|
if not rec:
|
|
raise HTTPException(404, "no such person")
|
|
return rec
|
|
|
|
|
|
@router.post("/people/{person_id}/reset", status_code=201)
|
|
def reset_link(request: Request, person_id: str, x_admin_token: str = Header(None)):
|
|
"""Mint a one-time password-reset link (24 h), returned ONCE. Their
|
|
current password keeps working until the link is used; using it ends
|
|
every session they have. Admin may reset anyone but an owner."""
|
|
actor = _people_actor(request, x_admin_token, "people:invite_admin")
|
|
try:
|
|
url = identity.create_reset(actor, person_id)
|
|
except identity.IdentityError as e:
|
|
raise HTTPException(e.status, e.detail)
|
|
if not url:
|
|
raise HTTPException(404, "no such person")
|
|
return {"url": url, "expires_hours": identity.RESET_TTL_HOURS}
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Roster (2026-09-04). roster:write, never on a script key. The program
|
|
# year is the site's PROGRAM_YEAR unless the caller names one.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def _year(year):
|
|
if year:
|
|
return year
|
|
import app as main_app
|
|
return getattr(main_app, "PROGRAM_YEAR", "2026-2027")
|
|
|
|
|
|
def _roster_actor(request, token):
|
|
_auth(request, token, "roster:write")
|
|
person = _person(request)
|
|
if not person:
|
|
raise HTTPException(403, "the roster needs a signed-in person")
|
|
return person
|
|
|
|
|
|
@router.get("/roster")
|
|
def get_roster(request: Request, unit: str = None, year: str = None, include_inactive: bool = False,
|
|
x_admin_token: str = Header(None)):
|
|
"""Households with their scouts and this year's checklist. `unit` is a
|
|
slug or id. Health forms are never stored: a check says one was
|
|
collected, by whom and when, and nothing else."""
|
|
_roster_actor(request, x_admin_token)
|
|
unit_id = None
|
|
if unit:
|
|
u = identity.get_unit(unit)
|
|
if not u:
|
|
raise HTTPException(404, "no such unit")
|
|
unit_id = u["id"]
|
|
y = _year(year)
|
|
return {"year": y, "items": list(store.ROSTER_ITEMS), "item_words": store.ROSTER_ITEM_WORDS,
|
|
"units": identity.list_units(), "households": store.list_roster(y, unit_id, include_inactive)}
|
|
|
|
|
|
@router.get("/roster/households/{hid}")
|
|
def get_household(request: Request, hid: str, year: str = None, x_admin_token: str = Header(None)):
|
|
_roster_actor(request, x_admin_token)
|
|
h = store.get_household(hid, _year(year))
|
|
if not h:
|
|
raise HTTPException(404, "no such household")
|
|
h["people"] = store.household_people(hid)
|
|
return h
|
|
|
|
|
|
@router.put("/roster/households/{hid}/people")
|
|
def put_household_people(request: Request, hid: str, payload: dict = Body(...), x_admin_token: str = Header(None)):
|
|
"""Which accounts belong to this family: body {person_ids: [...]}. This is
|
|
what a parent's Family page is built from. Admin and above."""
|
|
_auth(request, x_admin_token, "people:invite_leader")
|
|
if not _person(request):
|
|
raise HTTPException(403, "needs a signed-in person")
|
|
ids = [str(p) for p in payload.get("person_ids") or []]
|
|
known = {p["id"] for p in identity.list_people()}
|
|
bad = [p for p in ids if p not in known]
|
|
if bad:
|
|
raise HTTPException(422, "unknown person ids: %s" % ", ".join(bad))
|
|
res = store.set_household_people(hid, ids)
|
|
if res is None:
|
|
raise HTTPException(404, "no such household")
|
|
_log(request, "roster.household_people", "%s -> %d account(s)" % (hid, len(res)))
|
|
return {"person_ids": res}
|
|
|
|
|
|
@router.get("/family")
|
|
def my_family(request: Request, year: str = None, x_admin_token: str = Header(None)):
|
|
"""The signed-in person's own families: scouts, dens, and this year's
|
|
checklist as seen from the family's side. account:self, so a parent with
|
|
a member account gets exactly their own and nothing else."""
|
|
_auth(request, x_admin_token, "account:self")
|
|
person = _person(request)
|
|
if not person:
|
|
raise HTTPException(403, "needs a signed-in person")
|
|
y = _year(year)
|
|
return {"year": y, "items": list(store.ROSTER_ITEMS), "item_words": store.ROSTER_ITEM_WORDS,
|
|
"households": store.households_for_person(person["id"], y),
|
|
"me": {"email": person["email"], "name": person.get("preferred_name") or person.get("full_name")}}
|
|
|
|
|
|
@router.post("/roster/households", status_code=201)
|
|
def create_household(request: Request, payload: dict = Body(...), x_admin_token: str = Header(None)):
|
|
"""A family. Body: parent_name (required), email, phone, second_parent,
|
|
notes. Or `lead_id` alone to import a join lead as the family - the lead
|
|
is copied and linked, never changed."""
|
|
actor = _roster_actor(request, x_admin_token)
|
|
try:
|
|
if payload.get("lead_id"):
|
|
hid = store.import_lead(payload["lead_id"], created_by=actor["email"])
|
|
if not hid:
|
|
raise HTTPException(404, "no such lead")
|
|
_log(request, "roster.imported", "%s from lead %s" % (hid, payload["lead_id"]))
|
|
else:
|
|
hid = store.create_household(payload, created_by=actor["email"])
|
|
_log(request, "roster.household_created", "%s %s" % (hid, payload.get("parent_name")))
|
|
except store.RosterRejected as e:
|
|
raise _reject(e)
|
|
return store.get_household(hid, _year(None))
|
|
|
|
|
|
@router.patch("/roster/households/{hid}")
|
|
def patch_household(request: Request, hid: str, payload: dict = Body(...), x_admin_token: str = Header(None)):
|
|
_roster_actor(request, x_admin_token)
|
|
before = store.get_household(hid, _year(None))
|
|
try:
|
|
rec = store.update_household(hid, payload)
|
|
except store.RosterRejected as e:
|
|
raise _reject(e)
|
|
if not rec:
|
|
raise HTTPException(404, "no such household")
|
|
_log(request, "roster.household_updated", "%s: %s" % (hid, _diff(before, rec, list(store.HOUSEHOLD_FIELDS) + ["active"])))
|
|
return store.get_household(hid, _year(None))
|
|
|
|
|
|
@router.post("/roster/households/{hid}/scouts", status_code=201)
|
|
def add_scout(request: Request, hid: str, payload: dict = Body(...), x_admin_token: str = Header(None)):
|
|
"""Body: first_name (required), last_name, unit_id (required), den, bsa_member_id."""
|
|
_roster_actor(request, x_admin_token)
|
|
try:
|
|
rec = store.add_scout(hid, payload)
|
|
except store.RosterRejected as e:
|
|
raise _reject(e)
|
|
if not rec:
|
|
raise HTTPException(404, "no such household")
|
|
_log(request, "roster.scout_added", "%s %s (%s)" % (rec["id"], rec["first_name"], rec.get("den") or "-"))
|
|
return rec
|
|
|
|
|
|
@router.patch("/roster/scouts/{sid}")
|
|
def patch_scout(request: Request, sid: str, payload: dict = Body(...), x_admin_token: str = Header(None)):
|
|
_roster_actor(request, x_admin_token)
|
|
try:
|
|
res = store.update_scout(sid, payload)
|
|
except store.RosterRejected as e:
|
|
raise _reject(e)
|
|
if not res:
|
|
raise HTTPException(404, "no such scout")
|
|
before, after = res
|
|
_log(request, "roster.scout_updated", "%s %s: %s" % (sid, after["first_name"],
|
|
_diff(before, after, list(store.SCOUT_FIELDS) + ["active"])))
|
|
return after
|
|
|
|
|
|
@router.put("/roster/scouts/{sid}/checks/{item}")
|
|
def put_check(request: Request, sid: str, item: str, payload: dict = Body(...), year: str = None,
|
|
x_admin_token: str = Header(None)):
|
|
"""Mark an item collected for this year: body {done: true|false, note}.
|
|
Records who and when. `dues` and `health_form` today."""
|
|
actor = _roster_actor(request, x_admin_token)
|
|
try:
|
|
rec = store.set_check(sid, _year(year), item, bool(payload.get("done")), done_by=actor["email"],
|
|
note=payload.get("note"))
|
|
except store.RosterRejected as e:
|
|
raise _reject(e)
|
|
if not rec:
|
|
raise HTTPException(404, "no such scout")
|
|
_log(request, "roster.check", "%s %s %s -> %s" % (sid, _year(year), item, "done" if payload.get("done") else "cleared"))
|
|
return rec
|