feat(sharing): group-shared calendars in every member's sidebar, person share picker, hidden profiles
- Backend: co-member group_visible calendars now surface in /local/calendars
(owned=false, shared_by=owner, read-only, group_shared) and in the normal
/caldav/events merge (via readable_local_calendar_ids), deduped against
direct shares / group calendars so nothing appears twice.
- Backend: new User.directory_hidden — a user can hide from sharing/group
pickers (/users/directory), while admin user management (/users/) still lists
them. Migration + profile GET/PUT.
- Backend: /groups/{id} members carry shares_calendar so clients can drop
phantom rows for members who share nothing.
- Frontend: reachable "Teilen" button on owned local calendars; share modal is
now a checkbox multi-select of users (checked = shared). Hidden-profile toggle
in Settings → Profile. Group member filter only lists members who actually
share (phantom fix). Calendars shared with me moved to a dedicated read-only
"shared with me" section in the manage table.
- Tests: group_visible propagation, no-share absence, dedup, directory_hidden.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -176,11 +176,22 @@ def _group_detail(db: Session, group: models.Group, current_user: models.User) -
|
||||
member_dicts = []
|
||||
for i, m in enumerate(members):
|
||||
u = db.query(models.User).filter(models.User.id == m.user_id).first()
|
||||
# Whether this member actually shares a calendar into the group (owns a
|
||||
# calendar designated as their group_visible). Lets clients hide phantom
|
||||
# empty rows for members who share nothing.
|
||||
s = db.query(models.UserSettings).filter(models.UserSettings.user_id == m.user_id).first()
|
||||
shares_calendar = False
|
||||
if s and s.group_visible_calendar_id is not None:
|
||||
shares_calendar = db.query(models.LocalCalendar.id).filter(
|
||||
models.LocalCalendar.id == s.group_visible_calendar_id,
|
||||
models.LocalCalendar.user_id == m.user_id,
|
||||
).first() is not None
|
||||
member_dicts.append({
|
||||
"id": m.user_id,
|
||||
"display_name": (u.display_name or u.username) if u else None,
|
||||
"role": m.role,
|
||||
"color": m.color or MEMBER_PALETTE[i % len(MEMBER_PALETTE)],
|
||||
"shares_calendar": shares_calendar,
|
||||
})
|
||||
gcal_id = _group_calendar_id(db, group.id)
|
||||
return {
|
||||
|
||||
@@ -167,6 +167,23 @@ def list_calendars(
|
||||
d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write")
|
||||
d["group"] = True
|
||||
result.append(d)
|
||||
|
||||
# Calendars co-members share into shared groups (group_visible_calendar_id).
|
||||
# Read-only, shown under the owner's name. Deduped against everything above
|
||||
# so a calendar already shared directly / as a group calendar isn't doubled.
|
||||
for cal in permissions.co_member_group_visible_calendars(db, current_user):
|
||||
if cal.id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(cal.id)
|
||||
owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
|
||||
d = _cal_dict(
|
||||
cal, owned=False,
|
||||
shared_by=(owner.display_name or owner.username) if owner else None,
|
||||
permission="read",
|
||||
request=request,
|
||||
)
|
||||
d["group_shared"] = True
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ class ProfileUpdate(BaseModel):
|
||||
email: Optional[str] = Field(default=None, max_length=120)
|
||||
display_name: Optional[str] = Field(default=None, max_length=80)
|
||||
username: Optional[str] = Field(default=None, max_length=50) # login name (stored lowercase)
|
||||
directory_hidden: Optional[bool] = None # hide from sharing/group pickers
|
||||
|
||||
|
||||
def _strip_controls(s: str) -> str:
|
||||
@@ -66,6 +67,7 @@ def get_profile(current_user: models.User = Depends(get_current_user)):
|
||||
"is_admin": current_user.is_admin,
|
||||
"has_avatar": current_user.avatar_filename is not None,
|
||||
"totp_enabled": current_user.totp_enabled,
|
||||
"directory_hidden": bool(current_user.directory_hidden),
|
||||
}
|
||||
|
||||
|
||||
@@ -109,11 +111,15 @@ def update_profile(
|
||||
if taken:
|
||||
raise HTTPException(400, "Username already taken")
|
||||
current_user.username = new_login
|
||||
if data.directory_hidden is not None:
|
||||
current_user.directory_hidden = data.directory_hidden
|
||||
db.commit()
|
||||
# The JWT 'sub' is the login name — renaming it invalidates the old
|
||||
# token, so hand back a fresh one for the client to store.
|
||||
result["access_token"] = create_access_token({"sub": new_login})
|
||||
return result
|
||||
if data.directory_hidden is not None:
|
||||
current_user.directory_hidden = data.directory_hidden
|
||||
db.commit()
|
||||
return result
|
||||
|
||||
|
||||
@@ -53,7 +53,10 @@ def user_directory(
|
||||
"""
|
||||
users = (
|
||||
db.query(models.User)
|
||||
.filter(models.User.id != current_user.id)
|
||||
.filter(
|
||||
models.User.id != current_user.id,
|
||||
models.User.directory_hidden == False, # noqa: E712 — hidden users opt out of pickers
|
||||
)
|
||||
.order_by(models.User.username)
|
||||
.all()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user