This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

tools.book: apply calendar edits to bookings

+123 -3
+2 -1
configs/book.nix
··· 35 35 horizonDays = 28; 36 36 # lass/calendar is the only calendar (configs/radicale.nix): free/busy is 37 37 # read from it, and requests and bookings are mirrored into it, next to 38 - # lass's own events and whatever Hermes adds. 38 + # lass's own events and whatever Hermes adds. Book reads its events back: 39 + # deleting, moving or confirming one there manages the booking. 39 40 caldav = { 40 41 url = "http://127.0.0.1:5232"; 41 42 user = "lass";
+109 -2
tools/book/book.py
··· 10 10 the held requests themselves. If the calendar cannot be read, no slots are 11 11 offered: an unreachable calendar must never look like an empty one. 12 12 13 + The calendar is also a control surface: every minute the events of bookings 14 + that have not started yet are read back. Deleting (or cancelling) one cancels 15 + an approved booking or declines a request, moving one reschedules it, and 16 + confirming a tentative request approves it -- each with the same mail to the 17 + guest as the admin page would send. A calendar that cannot be read changes 18 + nothing. 19 + 13 20 The only mail a stranger can trigger goes to the owner. Guests receive mail 14 21 only after the owner acted on their request, so the form cannot be abused to 15 22 send mail to arbitrary addresses. ··· 189 196 return any(a0 < b1 and b0 < a1 for b0, b1 in intervals) 190 197 191 198 199 + def booking_event(data: bytes, uid: str) -> icalendar.Event: 200 + """The booking's VEVENT (not an overridden occurrence) from its calendar object. 201 + 202 + ValueError when the object cannot be read as one: that is never a deletion. 203 + """ 204 + cal = icalendar.Calendar.from_ical(data) 205 + for ev in cal.walk("VEVENT"): 206 + if str(ev.get("UID", "")) == uid and "RECURRENCE-ID" not in ev: 207 + return ev 208 + raise ValueError(f"no VEVENT with UID {uid}") 209 + 210 + 192 211 # --- CalDAV ------------------------------------------------------------------ 193 212 194 213 ··· 197 216 198 217 199 218 class CalDAV: 200 - """Just enough CalDAV for one user's home: list, query, put, delete.""" 219 + """Just enough CalDAV for one user's home: list, query, get, put, delete.""" 201 220 202 221 def __init__(self, cfg: Config): 203 222 self.base = cfg.caldav_url ··· 332 351 def delete(self, uid: str) -> None: 333 352 self._request("DELETE", f"{self.calendar}{quote(uid)}.ics", accept=(404,)) 334 353 354 + def get(self, uid: str) -> bytes | None: 355 + """The booking's calendar object, None once it is gone from the calendar.""" 356 + status, data = self._request("GET", f"{self.calendar}{quote(uid)}.ics", accept=(404,)) 357 + return None if status == 404 else data 358 + 335 359 336 360 # --- storage ----------------------------------------------------------------- 337 361 ··· 411 435 return self.db.execute( 412 436 "SELECT * FROM bookings WHERE status = 'pending' AND start <= ?", (epoch(now),) 413 437 ).fetchall() 438 + 439 + def upcoming(self, now: datetime) -> list[sqlite3.Row]: 440 + """Active bookings that have not started yet: the ones the calendar can still change. 441 + 442 + Started ones are left alone, so tidying up a running or past meeting in 443 + the calendar never mails the guest. 444 + """ 445 + with self.lock: 446 + return self.db.execute( 447 + "SELECT * FROM bookings WHERE status IN (?, ?) AND start > ?", (*ACTIVE, epoch(now)) 448 + ).fetchall() 449 + 450 + def reschedule(self, booking_id: int, start: datetime, end: datetime) -> None: 451 + with self.lock: 452 + self.db.execute( 453 + "UPDATE bookings SET start = ?, end = ?, sequence = sequence + 1, updated = ? WHERE id = ?", 454 + (epoch(start), epoch(end), epoch(now_utc()), booking_id), 455 + ) 414 456 415 457 416 458 # --- the application --------------------------------------------------------- ··· 655 697 for b in self.store.overdue(now_utc()): 656 698 log.info("expiring unanswered request %s", b["uid"]) 657 699 self.transition(b, "expired") 700 + 701 + def reschedule(self, b: sqlite3.Row, start: datetime, end: datetime) -> sqlite3.Row: 702 + """Move a booking, mirror it and send an approved guest the updated invitation.""" 703 + cfg = self.cfg 704 + with self.store.lock: 705 + current = self.store.get(b["id"]) 706 + if current["status"] not in ACTIVE: 707 + return current 708 + before = self.when(current, ZoneInfo(current["guest_tz"])) 709 + self.store.reschedule(b["id"], start, end) 710 + b = self.store.get(b["id"]) 711 + self.mirror(b) 712 + if b["status"] == "approved": 713 + guest_when = self.when(b, ZoneInfo(b["guest_tz"])) 714 + self.send( 715 + b["email"], 716 + f"Rescheduled: {b['title']} with {cfg.owner}, {guest_when}", 717 + self.render_text("mail/rescheduled.txt", b=b, when=guest_when, before=before), 718 + reply_to=cfg.notify, 719 + ics=self.ics(b, owner_copy=False, method="REQUEST"), 720 + method="REQUEST", 721 + ) 722 + return b 723 + 724 + def reconcile(self) -> None: 725 + """Apply what the owner changed on booking events in the calendar. 726 + 727 + CalDAVError propagates: an unreadable calendar must never be taken for 728 + deleted events. 729 + """ 730 + for b in self.store.upcoming(now_utc()): 731 + data = self.cal.get(b["uid"]) 732 + if data is None: 733 + ev, status = None, "CANCELLED" 734 + else: 735 + try: 736 + ev = booking_event(data, b["uid"]) 737 + except ValueError: 738 + log.exception("booking %s is unreadable in the calendar; left as is", b["uid"]) 739 + continue 740 + status = str(ev.get("STATUS", "")).upper() 741 + if status == "CANCELLED": 742 + log.info("booking %s removed from the calendar", b["uid"]) 743 + self.transition(b, "cancelled" if b["status"] == "approved" else "declined") 744 + continue 745 + try: 746 + start, end = to_utc(ev.start, self.cfg.tz), to_utc(ev.end, self.cfg.tz) 747 + except Exception: 748 + log.exception("booking %s has no usable start/end in the calendar", b["uid"]) 749 + continue 750 + if end <= start: 751 + log.error("booking %s ends before it starts in the calendar; ignored", b["uid"]) 752 + continue 753 + if (epoch(start), epoch(end)) != (b["start"], b["end"]): 754 + log.info("booking %s moved in the calendar", b["uid"]) 755 + b = self.reschedule(b, start, end) 756 + if b["status"] == "pending" and status == "CONFIRMED": 757 + log.info("request %s confirmed in the calendar", b["uid"]) 758 + self.transition(b, "approved") 658 759 659 760 def rate_limited(self, ip: str, limit: int = 5, window: float = 3600) -> bool: 660 761 with self.store.lock: ··· 977 1078 app.sweep() 978 1079 except Exception: 979 1080 log.exception("sweep failed") 980 - time.sleep(300) 1081 + try: 1082 + app.reconcile() 1083 + except CalDAVError as e: 1084 + log.error("cannot read bookings back from the calendar: %s", e) 1085 + except Exception: 1086 + log.exception("reconcile failed") 1087 + time.sleep(60) 981 1088 982 1089 threading.Thread(target=sweeper, daemon=True).start() 983 1090 Handler.app = app
+12
tools/book/templates/mail/rescheduled.txt
··· 1 + Hi {{ b.name }}, 2 + 3 + the time of this meeting changed: 4 + 5 + {{ b.title }} with {{ cfg.owner }} 6 + {{ when }} 7 + (was: {{ before }}) 8 + 9 + The updated invitation is attached. To cancel: 10 + {{ cfg.base_url }}/r/{{ b.guest_token }} 11 + 12 + {{ cfg.owner }}