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:
@@ -244,6 +244,14 @@ def _migrate():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Hide a user from sharing/group pickers (admin management still shows them).
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN directory_hidden BOOLEAN DEFAULT 0"))
|
||||
conn.commit()
|
||||
logging.info("Migration: added directory_hidden to users")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_migrate()
|
||||
|
||||
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
|
||||
|
||||
@@ -17,6 +17,9 @@ class User(Base):
|
||||
avatar_filename = Column(String(255), nullable=True)
|
||||
totp_secret = Column(String(32), nullable=True)
|
||||
totp_enabled = Column(Boolean, default=False)
|
||||
# When true, the user is hidden from sharing/group picker directories
|
||||
# (/users/directory). Admin user management (/users/) still shows them.
|
||||
directory_hidden = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
caldav_accounts = relationship(
|
||||
"CalDAVAccount", back_populates="user", cascade="all, delete-orphan"
|
||||
|
||||
@@ -97,8 +97,44 @@ def is_calendar_owner(db: Session, user: models.User, calendar_id: int) -> model
|
||||
return cal
|
||||
|
||||
|
||||
def co_member_group_visible_calendars(db: Session, user: models.User) -> list[models.LocalCalendar]:
|
||||
"""Calendars that co-members of the user's groups share into the group.
|
||||
|
||||
Each user designates ONE of their own calendars via
|
||||
UserSettings.group_visible_calendar_id. This returns those calendars for
|
||||
every co-member of any group the user belongs to (excluding the user's own).
|
||||
The calendar must be owned by the designating member. Deduped (each calendar
|
||||
appears once even across multiple shared groups).
|
||||
"""
|
||||
my_group_ids = (
|
||||
db.query(models.GroupMember.group_id)
|
||||
.filter(models.GroupMember.user_id == user.id)
|
||||
)
|
||||
co_member_ids = (
|
||||
db.query(models.GroupMember.user_id)
|
||||
.filter(
|
||||
models.GroupMember.group_id.in_(my_group_ids),
|
||||
models.GroupMember.user_id != user.id,
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
return (
|
||||
db.query(models.LocalCalendar)
|
||||
.join(
|
||||
models.UserSettings,
|
||||
models.UserSettings.group_visible_calendar_id == models.LocalCalendar.id,
|
||||
)
|
||||
.filter(
|
||||
models.UserSettings.user_id.in_(co_member_ids),
|
||||
# the designating member must own the calendar they share
|
||||
models.LocalCalendar.user_id == models.UserSettings.user_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]:
|
||||
"""All local calendar ids the user may read: own + shared + group calendars."""
|
||||
"""All local calendar ids the user may read: own + shared + group + co-member group-visible."""
|
||||
ids: set[int] = set()
|
||||
|
||||
own = (
|
||||
@@ -123,4 +159,7 @@ def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]:
|
||||
)
|
||||
ids.update(r[0] for r in group_cals)
|
||||
|
||||
# Calendars co-members share into shared groups (group-visible).
|
||||
ids.update(c.id for c in co_member_group_visible_calendars(db, user))
|
||||
|
||||
return list(ids)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -409,3 +409,78 @@ def test_import_export_only_local(client):
|
||||
cal_id = _make_calendar(client, admin, "Privat")
|
||||
# Bob has no access -> 404 on export.
|
||||
assert client.get(f"/api/local/calendars/{cal_id}/export", headers=auth(b_tok)).status_code == 404
|
||||
|
||||
|
||||
# ── Group-visible calendars propagate to co-members' sidebars ─────────────
|
||||
|
||||
def test_group_visible_propagates_to_member_sidebar(client):
|
||||
admin = register_admin(client)
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
client.post("/api/groups/", headers=auth(admin),
|
||||
json={"name": "Team", "member_ids": [b_id]})
|
||||
|
||||
# Bob designates a calendar as group-visible; admin (co-member) should see it.
|
||||
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
|
||||
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
|
||||
_make_event(client, b_tok, b_cal, "Bobs Termin")
|
||||
|
||||
cals = client.get("/api/local/calendars", headers=auth(admin)).json()
|
||||
shared = [c for c in cals if c["id"] == b_cal]
|
||||
assert len(shared) == 1, shared
|
||||
assert shared[0]["owned"] is False
|
||||
assert shared[0]["shared_by"] == "bob"
|
||||
assert shared[0]["permission"] == "read"
|
||||
assert shared[0].get("group_shared") is True
|
||||
|
||||
# Its events appear in the normal merged read, read-only.
|
||||
events = client.get("/api/caldav/events", headers=auth(admin), params=RANGE).json()["events"]
|
||||
bob_ev = [e for e in events if e["title"] == "Bobs Termin"]
|
||||
assert bob_ev and bob_ev[0].get("read_only") is True
|
||||
|
||||
|
||||
def test_group_visible_absent_when_not_designated(client):
|
||||
admin = register_admin(client)
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
client.post("/api/groups/", headers=auth(admin),
|
||||
json={"name": "Team", "member_ids": [b_id]})
|
||||
b_cal = _make_calendar(client, b_tok, "Bobs Kalender") # never designated
|
||||
cals = client.get("/api/local/calendars", headers=auth(admin)).json()
|
||||
assert not any(c["id"] == b_cal for c in cals)
|
||||
|
||||
|
||||
def test_group_visible_not_duplicated_with_direct_share(client):
|
||||
admin = register_admin(client)
|
||||
admin_id = client.get("/api/profile/", headers=auth(admin)).json()["id"]
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
client.post("/api/groups/", headers=auth(admin),
|
||||
json={"name": "Team", "member_ids": [b_id]})
|
||||
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
|
||||
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
|
||||
# Also directly shared with admin (read_write) — must not double-list.
|
||||
client.post(f"/api/local/calendars/{b_cal}/shares", headers=auth(b_tok),
|
||||
json={"user_id": admin_id, "permission": "read_write"})
|
||||
cals = client.get("/api/local/calendars", headers=auth(admin)).json()
|
||||
matches = [c for c in cals if c["id"] == b_cal]
|
||||
assert len(matches) == 1, matches
|
||||
# The direct share wins (read_write, not flagged group_shared).
|
||||
assert matches[0]["permission"] == "read_write"
|
||||
assert matches[0].get("group_shared") is not True
|
||||
|
||||
|
||||
# ── Hidden profile (directory opt-out) ────────────────────────────────────
|
||||
|
||||
def test_directory_hidden_excludes_from_picker_but_not_admin(client):
|
||||
admin = register_admin(client)
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
|
||||
assert any(u["id"] == b_id for u in
|
||||
client.get("/api/users/directory", headers=auth(admin)).json())
|
||||
|
||||
r = client.put("/api/profile/", headers=auth(b_tok), json={"directory_hidden": True})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
assert not any(u["id"] == b_id for u in
|
||||
client.get("/api/users/directory", headers=auth(admin)).json())
|
||||
# Admin user management still lists the hidden user.
|
||||
assert any(u["id"] == b_id for u in
|
||||
client.get("/api/users/", headers=auth(admin)).json())
|
||||
|
||||
Reference in New Issue
Block a user