P4: calendar write-back to Radicale, site-owned UIDs only

The site becomes a second writer to scouts/site73, as the scoped scoutsite
principal (rw on that one collection, denied everywhere else by the rights
file). Two rules enforced in calendar_write.py, not left to callers: the
site owns only UIDs ending @site73.greenlanescouts73.org and refuses any
other before a network call, the same shape as band-cal-sync and @band.us;
and with no RADICALE_* configuration every write is a 503, never a silent
no-op.

The VEVENT layout matches seed.py exactly so the feed reverses it into the
row shape the public pages already render: all-day DTEND exclusive, TZID +
VTIMEZONE on timed events, 90-minute default for a timed one-day event,
noon on the end date for a timed multi-day one, CATEGORIES for the unit,
X-SCOUT73-BADGE, 75-octet folding. Reads come from the scout-calendar feed
(now carrying uid and recurring); rows the site created and that are not
part of a series are marked mine. Writes are logged to auth_events and
bust the page cache so a leader sees their event within the feed's minute.

DELETE really deletes - the calendar's history is Radicale's git log.
Endpoints under calendar:write. tests/smoke_admin.py 78 -> 102. Proven
against the real store on a 2036 probe (outside the feed window): create,
read back byte-for-byte, replace, delete, second delete 404, foreign UID
403 with no store call, unconfigured 503.
This commit is contained in:
2026-09-04 17:56:55 -04:00
parent c458b8e64d
commit e52cc60fcf
4 changed files with 411 additions and 0 deletions
+103
View File
@@ -48,6 +48,7 @@ from fastapi import APIRouter, Body, Header, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
import auth
import calendar_write
import identity
import store
@@ -498,3 +499,105 @@ def api_docs(request: Request, x_admin_token: str = Header(None)):
"<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."""
actor = _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)
identity.log_event("calendar.created", email=actor if "@" in actor else None,
detail="%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."""
actor = _auth(request, x_admin_token, "calendar:write")
if not calendar_write.owns(uid):
raise HTTPException(403, "the site only manages events it created")
try:
ev = calendar_write.clean(payload)
calendar_write.put_event(uid, ev)
except calendar_write.CalendarRejected as e:
raise HTTPException(e.status, e.detail)
identity.log_event("calendar.updated", email=actor if "@" in actor else None,
detail="%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."""
actor = _auth(request, x_admin_token, "calendar:write")
if not calendar_write.owns(uid):
raise HTTPException(403, "the site only manages events it created")
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")
identity.log_event("calendar.deleted", email=actor if "@" in actor else None, detail=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