From 25f015a15b40e3a00dfe4af6da3196166d7f922f Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 24 Oct 2026 17:20:14 +0200 Subject: [PATCH] Let each person decide whether a shared calendar reminds them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reminders_enabled belongs to the calendar, so it belongs to its owner: whoever shares a calendar decides whether it notifies everyone it reaches, and a recipient's PUT is refused with 404 by the owner-only filter. For a group calendar that is worse than it sounds — the creator owns it, so silencing it for themselves would silence every member. CalendarReminderPref follows CalendarColorPref exactly, which already solves this for colours. Only local calendars are shareable (CalendarShare, GroupCalendar and group_visible_calendar_id all point at local_calendars.id), so no source discrimination is needed and the other four routers are untouched. No migration: create_all makes new tables, which is why calendar_color_prefs has no migration entry either. A recipient without an override gets reminders on, rather than inheriting the owner's choice. Muting is personal and should not spread. The new endpoint decides owner-vs-override server-side, so the existing owner-only PUT stays the guard it already is, and GET keeps returning reminders_enabled — now carrying the effective value, so no client model has to change. Birthday reminders were injected without consulting the flag at all, so a muted birthday calendar still handed the schedulers something to fire. Co-Authored-By: Claude Opus 5 --- backend/local_events_util.py | 6 +++- backend/models.py | 18 +++++++++++ backend/permissions.py | 11 +++++++ backend/routers/local_router.py | 53 +++++++++++++++++++++++++++++++-- 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/backend/local_events_util.py b/backend/local_events_util.py index 28ca03a..83b23ce 100644 --- a/backend/local_events_util.py +++ b/backend/local_events_util.py @@ -158,7 +158,11 @@ def build_local_event_dict( except (ValueError, TypeError): pass d["display_title"] = display - if not d["reminders"] and cal.birthday_notify_days_before is not None: + # Honour the calendar's mute: injecting a reminder here would hand the + # schedulers something to fire for a calendar the user silenced. + if (not d["reminders"] + and cal.birthday_notify_days_before is not None + and getattr(cal, "reminders_enabled", True)): d["reminders"] = [int(cal.birthday_notify_days_before) * 1440] if owner is not None: d["owner"] = owner diff --git a/backend/models.py b/backend/models.py index cdc77ce..c63d20a 100644 --- a/backend/models.py +++ b/backend/models.py @@ -432,6 +432,24 @@ class CalendarColorPref(Base): color = Column(String(16), nullable=False) +class CalendarReminderPref(Base): + """A user's personal reminder mute for a calendar they don't own — works for + any way a foreign calendar becomes visible (direct share, group calendar, or + a co-member's group-visible calendar). Absent = reminders on, regardless of + what the owner chose: muting is a personal decision and must not spread from + the owner to everyone a calendar reaches.""" + + __tablename__ = "calendar_reminder_prefs" + __table_args__ = ( + UniqueConstraint("calendar_id", "user_id", name="uq_calendar_reminder_pref"), + ) + + id = Column(Integer, primary_key=True, index=True) + calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + enabled = Column(Boolean, nullable=False) + + class Group(Base): __tablename__ = "groups" diff --git a/backend/permissions.py b/backend/permissions.py index d7aba3b..8767682 100644 --- a/backend/permissions.py +++ b/backend/permissions.py @@ -187,6 +187,17 @@ def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]: return list(ids) +def reminder_prefs_for(db: Session, user_id: int) -> dict[int, bool]: + """Map calendar_id -> the user's personal reminder flag for calendars they + don't own (any sharing path). Empty when the user set no overrides.""" + return { + p.calendar_id: bool(p.enabled) + for p in db.query(models.CalendarReminderPref).filter( + models.CalendarReminderPref.user_id == user_id + ) + } + + def color_prefs_for(db: Session, user_id: int) -> dict[int, str]: """Map calendar_id -> the user's personal colour for calendars they don't own (any sharing path). Empty when the user set no overrides.""" diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py index b3c0d26..8454fd5 100644 --- a/backend/routers/local_router.py +++ b/backend/routers/local_router.py @@ -81,6 +81,7 @@ class ShareCreate(BaseModel): def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True, shared_by: Optional[str] = None, permission: Optional[str] = None, color_override: Optional[str] = None, + reminders_override: Optional[bool] = None, request: Optional[Request] = None) -> dict: d = { "id": cal.id, @@ -91,7 +92,9 @@ def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True, "color": color_override or cal.color, "enabled": cal.enabled, "sidebar_hidden": bool(cal.sidebar_hidden), - "reminders_enabled": bool(cal.reminders_enabled), + # A recipient's own choice wins over the owner's flag. + "reminders_enabled": bool(cal.reminders_enabled) + if reminders_override is None else reminders_override, "caldav_published": bool(cal.caldav_published), "is_birthday": bool(cal.is_birthday), "birthday_notify_days_before": cal.birthday_notify_days_before, @@ -141,6 +144,7 @@ def list_calendars( # Per-user colour overrides for calendars the user doesn't own (any sharing path). color_prefs = permissions.color_prefs_for(db, current_user.id) + reminder_prefs = permissions.reminder_prefs_for(db, current_user.id) # Own calendars own = ( @@ -175,6 +179,7 @@ def list_calendars( shared_by=(owner.display_name or owner.username) if owner else None, permission=share.permission, color_override=color_prefs.get(cal.id), + reminders_override=reminder_prefs.get(cal.id, True), ) if cal.id in group_cal_map: d["group"] = True @@ -190,7 +195,8 @@ def list_calendars( continue seen_ids.add(cal_id) d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write", - color_override=color_prefs.get(cal.id)) + color_override=color_prefs.get(cal.id), + reminders_override=reminder_prefs.get(cal.id, True)) d["group"] = True result.append(d) @@ -207,6 +213,7 @@ def list_calendars( shared_by=(owner.display_name or owner.username) if owner else None, permission="read", color_override=color_prefs.get(cal.id), + reminders_override=reminder_prefs.get(cal.id, True), request=request, ) d["group_shared"] = True @@ -353,6 +360,48 @@ def set_calendar_color( return {"ok": True, "color": data.color} +class CalendarRemindersUpdate(BaseModel): + reminders_enabled: bool + + +@router.put("/calendars/{calendar_id}/reminders") +def set_calendar_reminders( + calendar_id: int, + data: CalendarRemindersUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + """Mute or unmute a calendar's reminders. The owner flips the calendar's own + flag; anyone else who can see it (direct share, group calendar, or a + co-member's group-visible calendar) sets only their OWN override and never + touches the owner's. The existing PUT /calendars/{id} stays owner-only, + which is what keeps a recipient from reaching the owner's flag at all.""" + cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == calendar_id).first() + if cal is None: + raise HTTPException(404, "Calendar not found") + if cal.user_id == current_user.id: + cal.reminders_enabled = data.reminders_enabled + else: + if calendar_id not in permissions.readable_local_calendar_ids(db, current_user): + raise HTTPException(403, "You cannot access this calendar") + pref = ( + db.query(models.CalendarReminderPref) + .filter( + models.CalendarReminderPref.calendar_id == calendar_id, + models.CalendarReminderPref.user_id == current_user.id, + ) + .first() + ) + if pref: + pref.enabled = data.reminders_enabled + else: + db.add(models.CalendarReminderPref( + calendar_id=calendar_id, user_id=current_user.id, + enabled=data.reminders_enabled)) + db.commit() + return {"ok": True, "reminders_enabled": data.reminders_enabled} + + @router.post("/calendars/{calendar_id}/dav-token/rotate") def rotate_dav_token( calendar_id: int,