Nearby rows bump verified_at on every save and deactivate rather than delete. Unit edits are meeting fields only, gated by unit:write_own scoped to the unit in the URL. Settings are a typed key registry falling back to code defaults; find-a-unit now reads its source name and URL from it. link_url and contact reject attribute-breakout characters and the nearby renderer escapes quotes. Covered by tests/smoke_admin.py, 53 checks in-process.
296 lines
13 KiB
Python
296 lines
13 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 os
|
|
|
|
from fastapi import APIRouter, Body, Header, HTTPException, Query, Request
|
|
|
|
import auth
|
|
import identity
|
|
import store
|
|
|
|
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "").strip()
|
|
|
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
|
|
|
|
|
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 = auth.current_person(request)
|
|
if person:
|
|
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 ""))
|
|
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")
|
|
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.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:
|
|
return 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)
|
|
|
|
|
|
@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")
|
|
return store.get_announcement(announcement_id)
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# 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:
|
|
return store.create_nearby(payload)
|
|
except store.NearbyRejected as e:
|
|
raise _reject(e)
|
|
|
|
|
|
@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")
|
|
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")
|
|
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")
|
|
return store.get_nearby(nearby_id)
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# 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:
|
|
return identity.update_unit_meets(slug_or_id, payload)
|
|
except identity.IdentityError as e:
|
|
raise HTTPException(e.status, e.detail)
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# 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")
|
|
try:
|
|
return identity.set_setting(key, payload.get("value"), actor=actor)
|
|
except identity.IdentityError as e:
|
|
raise HTTPException(e.status, e.detail)
|