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:
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user