fix(sharing): per-user calendar colour works for every sharing path
The recipient colour was stored on calendar_shares, so it only worked for
DIRECT shares. A calendar made visible through a group (a co-member's
group_visible_calendar_id) has no CalendarShare row, so the colour endpoint
returned 403 and nothing was saved — the reported "colour picker opens but the
colour stays the same" for a group-shared calendar.
Replace the share-scoped colour with a general per-user override table
(calendar_color_prefs, keyed by user+calendar). PUT /calendars/{id}/color now
accepts any calendar the user can read (readable_local_calendar_ids covers
direct shares, group calendars AND group-visible), and the merge read + calendar
list apply the override for all of them. Owners still set the shared colour.
The new table is created by create_all; the old calendar_shares.color is unused.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -307,13 +307,28 @@ 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")
|
||||||
user = relationship("User")
|
user = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarColorPref(Base):
|
||||||
|
"""A user's personal colour 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). NULL/absent = the owner's colour."""
|
||||||
|
|
||||||
|
__tablename__ = "calendar_color_prefs"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("calendar_id", "user_id", name="uq_calendar_color_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)
|
||||||
|
color = Column(String(16), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class Group(Base):
|
class Group(Base):
|
||||||
__tablename__ = "groups"
|
__tablename__ = "groups"
|
||||||
|
|
||||||
|
|||||||
@@ -163,3 +163,14 @@ def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]:
|
|||||||
ids.update(c.id for c in co_member_group_visible_calendars(db, user))
|
ids.update(c.id for c in co_member_group_visible_calendars(db, user))
|
||||||
|
|
||||||
return list(ids)
|
return list(ids)
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
return {
|
||||||
|
p.calendar_id: p.color
|
||||||
|
for p in db.query(models.CalendarColorPref).filter(
|
||||||
|
models.CalendarColorPref.user_id == user_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -367,6 +367,8 @@ def get_events(
|
|||||||
models.GroupCalendar.calendar_id.in_(readable_ids)
|
models.GroupCalendar.calendar_id.in_(readable_ids)
|
||||||
)
|
)
|
||||||
} if readable_ids else set()
|
} if readable_ids else set()
|
||||||
|
# Per-user colour overrides for calendars the user doesn't own (any share path).
|
||||||
|
color_prefs = permissions.color_prefs_for(db, current_user.id)
|
||||||
# Cache each owner's private-event visibility (one lookup per owner, not per event).
|
# Cache each owner's private-event visibility (one lookup per owner, not per event).
|
||||||
vis_cache: dict = {}
|
vis_cache: dict = {}
|
||||||
|
|
||||||
@@ -384,7 +386,7 @@ def get_events(
|
|||||||
share = shares_by_cal.get(local_cal.id) if is_shared_personal else None
|
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 (share.permission if share else None) != "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
|
shared_color = color_prefs.get(local_cal.id)
|
||||||
local_events = (
|
local_events = (
|
||||||
db.query(models.LocalEvent)
|
db.query(models.LocalEvent)
|
||||||
.filter(
|
.filter(
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ 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)
|
||||||
|
|
||||||
# Own calendars
|
# Own calendars
|
||||||
own = (
|
own = (
|
||||||
db.query(models.LocalCalendar)
|
db.query(models.LocalCalendar)
|
||||||
@@ -152,7 +155,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,
|
color_override=color_prefs.get(cal.id),
|
||||||
)
|
)
|
||||||
if cal.id in group_cal_map:
|
if cal.id in group_cal_map:
|
||||||
d["group"] = True
|
d["group"] = True
|
||||||
@@ -167,7 +170,8 @@ def list_calendars(
|
|||||||
if not cal:
|
if not cal:
|
||||||
continue
|
continue
|
||||||
seen_ids.add(cal_id)
|
seen_ids.add(cal_id)
|
||||||
d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write")
|
d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write",
|
||||||
|
color_override=color_prefs.get(cal.id))
|
||||||
d["group"] = True
|
d["group"] = True
|
||||||
result.append(d)
|
result.append(d)
|
||||||
|
|
||||||
@@ -183,6 +187,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="read",
|
permission="read",
|
||||||
|
color_override=color_prefs.get(cal.id),
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
d["group_shared"] = True
|
d["group_shared"] = True
|
||||||
@@ -261,17 +266,30 @@ def set_calendar_color(
|
|||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Set a calendar's colour. The owner changes the calendar's colour for
|
"""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
|
everyone; anyone else who can see the calendar (direct share, group calendar,
|
||||||
the share) without touching the owner's. Recipients may recolour but never
|
or a co-member's group-visible calendar) sets only their OWN per-user colour.
|
||||||
rename a shared calendar."""
|
Recipients may recolour but never rename a shared calendar."""
|
||||||
cal = permissions.accessible_local_calendar(db, current_user, calendar_id)
|
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:
|
if cal.user_id == current_user.id:
|
||||||
cal.color = data.color
|
cal.color = data.color
|
||||||
else:
|
else:
|
||||||
share = permissions._share_for(db, calendar_id, current_user.id)
|
if calendar_id not in permissions.readable_local_calendar_ids(db, current_user):
|
||||||
if share is None:
|
raise HTTPException(403, "You cannot access this calendar")
|
||||||
raise HTTPException(403, "Only the owner or a share recipient can set the colour")
|
pref = (
|
||||||
share.color = data.color
|
db.query(models.CalendarColorPref)
|
||||||
|
.filter(
|
||||||
|
models.CalendarColorPref.calendar_id == calendar_id,
|
||||||
|
models.CalendarColorPref.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if pref:
|
||||||
|
pref.color = data.color
|
||||||
|
else:
|
||||||
|
db.add(models.CalendarColorPref(
|
||||||
|
calendar_id=calendar_id, user_id=current_user.id, color=data.color))
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "color": data.color}
|
return {"ok": True, "color": data.color}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user