From 343f7a5e7be21614502abc458945d59ae12b101e Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 6 Jul 2026 20:40:13 +0200 Subject: [PATCH] feat(sharing): recipients can recolour a shared calendar (per-user, no rename) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A share recipient couldn't change anything on a shared calendar (update_calendar is owner-only → 404), yet the clients still showed a colour picker for it. Add a per-recipient colour: new nullable calendar_shares.color column (+ migration). New PUT /calendars/{id}/color endpoint sets the calendar colour for the owner (global) or, for a recipient, only their own share colour — never the name, so recipients can recolour but not rename. The merge read and the calendar list now prefer the recipient's share colour over the owner's (NULL = owner's colour). Co-Authored-By: Claude Opus 4.8 --- backend/main.py | 7 +++++++ backend/models.py | 1 + backend/routers/caldav_router.py | 10 +++++++--- backend/routers/local_router.py | 32 +++++++++++++++++++++++++++++++- 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/backend/main.py b/backend/main.py index 815816e..5c4a343 100644 --- a/backend/main.py +++ b/backend/main.py @@ -243,6 +243,13 @@ def _migrate(): logging.info("Migration: added etag to local_events") except Exception: pass + # Per-recipient colour override for a shared calendar (NULL = owner's colour). + try: + conn.execute(text("ALTER TABLE calendar_shares ADD COLUMN color VARCHAR(16)")) + conn.commit() + logging.info("Migration: added color to calendar_shares") + except Exception: + pass # Hide a user from sharing/group pickers (admin management still shows them). try: diff --git a/backend/models.py b/backend/models.py index 0d35fe6..9aa1075 100644 --- a/backend/models.py +++ b/backend/models.py @@ -307,6 +307,7 @@ class CalendarShare(Base): calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) permission = Column(String(20), default="read") # 'read' | 'read_write' + color = Column(String(16), nullable=True) # recipient's own colour; NULL = owner's created_at = Column(String(50), nullable=True) # ISO 8601 calendar = relationship("LocalCalendar") diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py index edf1991..bf3e53d 100644 --- a/backend/routers/caldav_router.py +++ b/backend/routers/caldav_router.py @@ -356,8 +356,8 @@ def get_events( # owner's name (so Guido's "Persönlich" reads as "Guido" for his mum) and # flagged read_only unless the share grants write. Group calendars are # excluded — they keep their own name and stay writable for members. - shares_map = { - s.calendar_id: s.permission + shares_by_cal = { + s.calendar_id: s for s in db.query(models.CalendarShare).filter( models.CalendarShare.user_id == current_user.id ) @@ -381,8 +381,10 @@ def get_events( local_cal.user_id != current_user.id and local_cal.id not in group_cal_ids ) + share = shares_by_cal.get(local_cal.id) if is_shared_personal else None shared_owner_name = name_cache.get(local_cal.user_id) if is_shared_personal else None - shared_read_only = is_shared_personal and shares_map.get(local_cal.id) != "read_write" + shared_read_only = is_shared_personal and (share.permission if share else None) != "read_write" + shared_color = share.color if share else None local_events = ( db.query(models.LocalEvent) .filter( @@ -417,6 +419,8 @@ def get_events( b["calendar_name"] = shared_owner_name if shared_read_only: b["read_only"] = True + if shared_color: + b["calendarColor"] = shared_color b = apply_event_privacy( b, owner_id=owner_id, is_private=is_priv, requester_id=current_user.id, visibility=visibility, diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py index 7076616..471e7f2 100644 --- a/backend/routers/local_router.py +++ b/backend/routers/local_router.py @@ -70,11 +70,13 @@ 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, request: Optional[Request] = None) -> dict: d = { "id": cal.id, "name": cal.name, - "color": cal.color, + # A recipient's own colour for a shared calendar wins over the owner's. + "color": color_override or cal.color, "enabled": cal.enabled, "reminders_enabled": bool(cal.reminders_enabled), "caldav_published": bool(cal.caldav_published), @@ -150,6 +152,7 @@ def list_calendars( cal, owned=False, shared_by=(owner.display_name or owner.username) if owner else None, permission=share.permission, + color_override=share.color, ) if cal.id in group_cal_map: d["group"] = True @@ -246,6 +249,33 @@ def update_calendar( return _cal_dict(cal, owned=True, request=request) +class CalendarColorUpdate(BaseModel): + color: str + + +@router.put("/calendars/{calendar_id}/color") +def set_calendar_color( + calendar_id: int, + data: CalendarColorUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + """Set a calendar's colour. The owner changes the calendar's colour for + everyone; a share recipient sets only their OWN per-user colour (stored on + the share) without touching the owner's. Recipients may recolour but never + rename a shared calendar.""" + cal = permissions.accessible_local_calendar(db, current_user, calendar_id) + if cal.user_id == current_user.id: + cal.color = data.color + else: + share = permissions._share_for(db, calendar_id, current_user.id) + if share is None: + raise HTTPException(403, "Only the owner or a share recipient can set the colour") + share.color = data.color + db.commit() + return {"ok": True, "color": data.color} + + @router.post("/calendars/{calendar_id}/dav-token/rotate") def rotate_dav_token( calendar_id: int,