seeded events are the site's; every write lands in the action log

calendar_write.owns() now accepts both site namespaces: the 32 events the
2026-08-29 seed stamped site73-<sha1>@greenlanescouts73.org and the site's
own @site73.greenlanescouts73.org. The rule exists to keep the site off
@band.us and off anything a person adds in a calendar client, not off its
own data. Seeded objects are written back at the seeder's object name so
an edit replaces in place; proven on a throwaway against the real store
(edit, still 32 objects, restored byte-for-byte).

auth_events becomes the one action log. Every P2 write - nearby create,
update, deactivate; unit meeting edit; setting change; announcement post
and take-down - now records who, when, and a one-line before -> after for
the fields that changed, alongside the login, key and calendar entries
already there. GET /api/admin/history (history:read, admin and above, not
scopable on a key) reads it newest first with a kind prefix filter and
before= paging; rowid breaks second-resolution ties.

tests/smoke_admin.py 102 -> 112.
This commit is contained in:
2026-09-04 18:10:03 -04:00
parent e52cc60fcf
commit 7287f0b515
4 changed files with 160 additions and 23 deletions
+22 -3
View File
@@ -25,10 +25,16 @@ import datetime
import hashlib
import os
import urllib.error
import urllib.parse
import urllib.request
import uuid
UID_SUFFIX = "@site73.greenlanescouts73.org"
# The 2026-08-29 seed stamped its 32 events site73-<sha1>@greenlanescouts73.org
# before this namespace existed. They are the site's own data too; the rule
# exists to keep the site off @band.us and off anything a person adds in a
# calendar client, not off itself.
SEED_PREFIX, SEED_SUFFIX = "site73-", "@greenlanescouts73.org"
TZID = "America/New_York"
UNITS = ("pack", "troop", "both")
DEFAULT_TIMED_MINUTES = 90
@@ -70,7 +76,19 @@ def configured():
def owns(uid):
return bool(uid) and str(uid).endswith(UID_SUFFIX)
u = str(uid or "")
return bool(u) and (u.endswith(UID_SUFFIX) or (u.startswith(SEED_PREFIX) and u.endswith(SEED_SUFFIX)))
def object_name(uid):
"""The CalDAV object the UID lives in. Seeded events sit at <uid>.ics as
the seeder wrote them; the site's own at a hashed site-*.ics. Getting
this wrong would create a second object with the same UID, which Radicale
refuses, so the two forms are tested separately."""
u = str(uid)
if u.endswith(UID_SUFFIX):
return slug(u)
return urllib.parse.quote(u + ".ics", safe="")
def new_uid():
@@ -219,8 +237,9 @@ def _request(method, uid, data=None):
if not configured():
raise CalendarRejected(503, "calendar write-back is not configured (RADICALE_URL/USER/PASS)")
if not owns(uid):
raise CalendarRejected(403, "the site only manages events it created (UIDs ending %s)" % UID_SUFFIX)
url = RADICALE_URL.rstrip("/") + "/" + slug(uid)
raise CalendarRejected(403, "the site only manages its own events (%s or the seeded %s...%s)"
% (UID_SUFFIX, SEED_PREFIX, SEED_SUFFIX))
url = RADICALE_URL.rstrip("/") + "/" + object_name(uid)
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", "Basic " + base64.b64encode(
("%s:%s" % (RADICALE_USER, RADICALE_PASS)).encode()).decode())