···3535 horizonDays = 28;
3636 # lass/calendar is the only calendar (configs/radicale.nix): free/busy is
3737 # read from it, and requests and bookings are mirrored into it, next to
3838- # lass's own events and whatever Hermes adds.
3838+ # lass's own events and whatever Hermes adds. Book reads its events back:
3939+ # deleting, moving or confirming one there manages the booking.
3940 caldav = {
4041 url = "http://127.0.0.1:5232";
4142 user = "lass";
···1010the held requests themselves. If the calendar cannot be read, no slots are
1111offered: an unreachable calendar must never look like an empty one.
12121313+The calendar is also a control surface: every minute the events of bookings
1414+that have not started yet are read back. Deleting (or cancelling) one cancels
1515+an approved booking or declines a request, moving one reschedules it, and
1616+confirming a tentative request approves it -- each with the same mail to the
1717+guest as the admin page would send. A calendar that cannot be read changes
1818+nothing.
1919+1320The only mail a stranger can trigger goes to the owner. Guests receive mail
1421only after the owner acted on their request, so the form cannot be abused to
1522send mail to arbitrary addresses.
···189196 return any(a0 < b1 and b0 < a1 for b0, b1 in intervals)
190197191198199199+def booking_event(data: bytes, uid: str) -> icalendar.Event:
200200+ """The booking's VEVENT (not an overridden occurrence) from its calendar object.
201201+202202+ ValueError when the object cannot be read as one: that is never a deletion.
203203+ """
204204+ cal = icalendar.Calendar.from_ical(data)
205205+ for ev in cal.walk("VEVENT"):
206206+ if str(ev.get("UID", "")) == uid and "RECURRENCE-ID" not in ev:
207207+ return ev
208208+ raise ValueError(f"no VEVENT with UID {uid}")
209209+210210+192211# --- CalDAV ------------------------------------------------------------------
193212194213···197216198217199218class CalDAV:
200200- """Just enough CalDAV for one user's home: list, query, put, delete."""
219219+ """Just enough CalDAV for one user's home: list, query, get, put, delete."""
201220202221 def __init__(self, cfg: Config):
203222 self.base = cfg.caldav_url
···332351 def delete(self, uid: str) -> None:
333352 self._request("DELETE", f"{self.calendar}{quote(uid)}.ics", accept=(404,))
334353354354+ def get(self, uid: str) -> bytes | None:
355355+ """The booking's calendar object, None once it is gone from the calendar."""
356356+ status, data = self._request("GET", f"{self.calendar}{quote(uid)}.ics", accept=(404,))
357357+ return None if status == 404 else data
358358+335359336360# --- storage -----------------------------------------------------------------
337361···411435 return self.db.execute(
412436 "SELECT * FROM bookings WHERE status = 'pending' AND start <= ?", (epoch(now),)
413437 ).fetchall()
438438+439439+ def upcoming(self, now: datetime) -> list[sqlite3.Row]:
440440+ """Active bookings that have not started yet: the ones the calendar can still change.
441441+442442+ Started ones are left alone, so tidying up a running or past meeting in
443443+ the calendar never mails the guest.
444444+ """
445445+ with self.lock:
446446+ return self.db.execute(
447447+ "SELECT * FROM bookings WHERE status IN (?, ?) AND start > ?", (*ACTIVE, epoch(now))
448448+ ).fetchall()
449449+450450+ def reschedule(self, booking_id: int, start: datetime, end: datetime) -> None:
451451+ with self.lock:
452452+ self.db.execute(
453453+ "UPDATE bookings SET start = ?, end = ?, sequence = sequence + 1, updated = ? WHERE id = ?",
454454+ (epoch(start), epoch(end), epoch(now_utc()), booking_id),
455455+ )
414456415457416458# --- the application ---------------------------------------------------------
···655697 for b in self.store.overdue(now_utc()):
656698 log.info("expiring unanswered request %s", b["uid"])
657699 self.transition(b, "expired")
700700+701701+ def reschedule(self, b: sqlite3.Row, start: datetime, end: datetime) -> sqlite3.Row:
702702+ """Move a booking, mirror it and send an approved guest the updated invitation."""
703703+ cfg = self.cfg
704704+ with self.store.lock:
705705+ current = self.store.get(b["id"])
706706+ if current["status"] not in ACTIVE:
707707+ return current
708708+ before = self.when(current, ZoneInfo(current["guest_tz"]))
709709+ self.store.reschedule(b["id"], start, end)
710710+ b = self.store.get(b["id"])
711711+ self.mirror(b)
712712+ if b["status"] == "approved":
713713+ guest_when = self.when(b, ZoneInfo(b["guest_tz"]))
714714+ self.send(
715715+ b["email"],
716716+ f"Rescheduled: {b['title']} with {cfg.owner}, {guest_when}",
717717+ self.render_text("mail/rescheduled.txt", b=b, when=guest_when, before=before),
718718+ reply_to=cfg.notify,
719719+ ics=self.ics(b, owner_copy=False, method="REQUEST"),
720720+ method="REQUEST",
721721+ )
722722+ return b
723723+724724+ def reconcile(self) -> None:
725725+ """Apply what the owner changed on booking events in the calendar.
726726+727727+ CalDAVError propagates: an unreadable calendar must never be taken for
728728+ deleted events.
729729+ """
730730+ for b in self.store.upcoming(now_utc()):
731731+ data = self.cal.get(b["uid"])
732732+ if data is None:
733733+ ev, status = None, "CANCELLED"
734734+ else:
735735+ try:
736736+ ev = booking_event(data, b["uid"])
737737+ except ValueError:
738738+ log.exception("booking %s is unreadable in the calendar; left as is", b["uid"])
739739+ continue
740740+ status = str(ev.get("STATUS", "")).upper()
741741+ if status == "CANCELLED":
742742+ log.info("booking %s removed from the calendar", b["uid"])
743743+ self.transition(b, "cancelled" if b["status"] == "approved" else "declined")
744744+ continue
745745+ try:
746746+ start, end = to_utc(ev.start, self.cfg.tz), to_utc(ev.end, self.cfg.tz)
747747+ except Exception:
748748+ log.exception("booking %s has no usable start/end in the calendar", b["uid"])
749749+ continue
750750+ if end <= start:
751751+ log.error("booking %s ends before it starts in the calendar; ignored", b["uid"])
752752+ continue
753753+ if (epoch(start), epoch(end)) != (b["start"], b["end"]):
754754+ log.info("booking %s moved in the calendar", b["uid"])
755755+ b = self.reschedule(b, start, end)
756756+ if b["status"] == "pending" and status == "CONFIRMED":
757757+ log.info("request %s confirmed in the calendar", b["uid"])
758758+ self.transition(b, "approved")
658759659760 def rate_limited(self, ip: str, limit: int = 5, window: float = 3600) -> bool:
660761 with self.store.lock:
···9771078 app.sweep()
9781079 except Exception:
9791080 log.exception("sweep failed")
980980- time.sleep(300)
10811081+ try:
10821082+ app.reconcile()
10831083+ except CalDAVError as e:
10841084+ log.error("cannot read bookings back from the calendar: %s", e)
10851085+ except Exception:
10861086+ log.exception("reconcile failed")
10871087+ time.sleep(60)
98110889821089 threading.Thread(target=sweeper, daemon=True).start()
9831090 Handler.app = app