feat(sharing): recipients can recolour a shared calendar (per-user, no rename)

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-06 20:40:13 +02:00
parent cad48efcc6
commit 343f7a5e7b
4 changed files with 46 additions and 4 deletions

View File

@@ -243,6 +243,13 @@ def _migrate():
logging.info("Migration: added etag to local_events") logging.info("Migration: added etag to local_events")
except Exception: except Exception:
pass 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). # Hide a user from sharing/group pickers (admin management still shows them).
try: try:

View File

@@ -307,6 +307,7 @@ class CalendarShare(Base):
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False) calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
permission = Column(String(20), default="read") # 'read' | 'read_write' 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 created_at = Column(String(50), nullable=True) # ISO 8601
calendar = relationship("LocalCalendar") calendar = relationship("LocalCalendar")

View File

@@ -356,8 +356,8 @@ def get_events(
# owner's name (so Guido's "Persönlich" reads as "Guido" for his mum) and # 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 # flagged read_only unless the share grants write. Group calendars are
# excluded — they keep their own name and stay writable for members. # excluded — they keep their own name and stay writable for members.
shares_map = { shares_by_cal = {
s.calendar_id: s.permission s.calendar_id: s
for s in db.query(models.CalendarShare).filter( for s in db.query(models.CalendarShare).filter(
models.CalendarShare.user_id == current_user.id models.CalendarShare.user_id == current_user.id
) )
@@ -381,8 +381,10 @@ def get_events(
local_cal.user_id != current_user.id local_cal.user_id != current_user.id
and local_cal.id not in group_cal_ids 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_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 = ( local_events = (
db.query(models.LocalEvent) db.query(models.LocalEvent)
.filter( .filter(
@@ -417,6 +419,8 @@ def get_events(
b["calendar_name"] = shared_owner_name b["calendar_name"] = shared_owner_name
if shared_read_only: if shared_read_only:
b["read_only"] = True b["read_only"] = True
if shared_color:
b["calendarColor"] = shared_color
b = apply_event_privacy( b = apply_event_privacy(
b, owner_id=owner_id, is_private=is_priv, b, owner_id=owner_id, is_private=is_priv,
requester_id=current_user.id, visibility=visibility, requester_id=current_user.id, visibility=visibility,

View File

@@ -70,11 +70,13 @@ class ShareCreate(BaseModel):
def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True, def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
shared_by: Optional[str] = None, permission: Optional[str] = None, shared_by: Optional[str] = None, permission: Optional[str] = None,
color_override: Optional[str] = None,
request: Optional[Request] = None) -> dict: request: Optional[Request] = None) -> dict:
d = { d = {
"id": cal.id, "id": cal.id,
"name": cal.name, "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, "enabled": cal.enabled,
"reminders_enabled": bool(cal.reminders_enabled), "reminders_enabled": bool(cal.reminders_enabled),
"caldav_published": bool(cal.caldav_published), "caldav_published": bool(cal.caldav_published),
@@ -150,6 +152,7 @@ def list_calendars(
cal, owned=False, cal, owned=False,
shared_by=(owner.display_name or owner.username) if owner else None, shared_by=(owner.display_name or owner.username) if owner else None,
permission=share.permission, permission=share.permission,
color_override=share.color,
) )
if cal.id in group_cal_map: if cal.id in group_cal_map:
d["group"] = True d["group"] = True
@@ -246,6 +249,33 @@ def update_calendar(
return _cal_dict(cal, owned=True, request=request) 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") @router.post("/calendars/{calendar_id}/dav-token/rotate")
def rotate_dav_token( def rotate_dav_token(
calendar_id: int, calendar_id: int,