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