From 06ba9c2bb11229fa59d5a6e513e952e73e6c55e6 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 31 May 2026 16:57:42 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Gruppen-Sichtbarkeit=20=E2=80=93=20gena?= =?UTF-8?q?u=20ein=20designierter=20Kalender=20pro=20Person?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neues user_settings.group_visible_calendar_id: jedes Mitglied waehlt EINEN lokalen Kalender, der in seinen Gruppen sichtbar ist. Die kombinierte Ansicht ueberlagert nur diesen (statt aller) Kalender je Mitglied + den Gruppenkalender; private Termine weiter gefiltert. Settings GET/PUT erweitert (nullbar). Tests angepasst + ergaenzt (14 gruen). Co-Authored-By: Claude Opus 4.8 --- backend/main.py | 6 ++++++ backend/models.py | 3 +++ backend/routers/groups_router.py | 26 ++++++++++++++++++-------- backend/routers/settings_router.py | 4 +++- backend/tests/test_collaboration.py | 23 ++++++++++++++++++++++- 5 files changed, 52 insertions(+), 10 deletions(-) diff --git a/backend/main.py b/backend/main.py index 8d017ca..5fca9f3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -157,6 +157,12 @@ def _migrate(): logging.info("Migration: added is_private to local_events") except Exception: pass + try: + conn.execute(text("ALTER TABLE user_settings ADD COLUMN group_visible_calendar_id INTEGER")) + conn.commit() + logging.info("Migration: added group_visible_calendar_id to user_settings") + except Exception: + pass _migrate() diff --git a/backend/models.py b/backend/models.py index 5b3fb39..a8a6011 100644 --- a/backend/models.py +++ b/backend/models.py @@ -95,6 +95,9 @@ class UserSettings(Base): # How this user's private events appear to other group members: # 'hidden' = invisible, 'busy' = anonymous busy block (default). private_event_visibility = Column(String(10), default="busy") + # The single local calendar this user shares into all their groups + # (combined view shows only this calendar per member). NULL = share nothing. + group_visible_calendar_id = Column(Integer, nullable=True) user = relationship("User", back_populates="settings") diff --git a/backend/routers/groups_router.py b/backend/routers/groups_router.py index f3314e1..c427866 100644 --- a/backend/routers/groups_router.py +++ b/backend/routers/groups_router.py @@ -313,16 +313,26 @@ def combined_events( b = _strip_busy(b) all_events.append(b) - # Each member's own local calendars (excluding the group calendar to avoid dupes). + # Each member shares exactly one calendar into their groups, chosen in their + # settings (group_visible_calendar_id). Only that calendar is overlaid. for m in members: - member_cals = ( - db.query(models.LocalCalendar) - .filter(models.LocalCalendar.user_id == m.user_id) - .all() + settings = ( + db.query(models.UserSettings) + .filter(models.UserSettings.user_id == m.user_id) + .first() ) - for cal in member_cals: - if group_cal_id is not None and cal.id == group_cal_id: - continue + visible_id = settings.group_visible_calendar_id if settings else None + if visible_id is None or visible_id == group_cal_id: + continue + cal = ( + db.query(models.LocalCalendar) + .filter( + models.LocalCalendar.id == visible_id, + models.LocalCalendar.user_id == m.user_id, # must be the member's own + ) + .first() + ) + if cal: emit_calendar(cal, m.user_id, is_group=False) # The group calendar itself. diff --git a/backend/routers/settings_router.py b/backend/routers/settings_router.py index 1caad75..cc140b0 100644 --- a/backend/routers/settings_router.py +++ b/backend/routers/settings_router.py @@ -28,6 +28,7 @@ class SettingsUpdate(BaseModel): line_color: Optional[str] = None bg_color: Optional[str] = None private_event_visibility: Optional[str] = None + group_visible_calendar_id: Optional[int] = None def _settings_dict(s: models.UserSettings) -> dict: @@ -48,6 +49,7 @@ def _settings_dict(s: models.UserSettings) -> dict: "line_color": s.line_color, "bg_color": s.bg_color, "private_event_visibility": s.private_event_visibility or "busy", + "group_visible_calendar_id": s.group_visible_calendar_id, } @@ -90,7 +92,7 @@ def update_settings( # For these three override colours, an explicit null is meaningful # ("reset to default") and must be persisted as NULL. All other fields # keep the previous behaviour where a null/missing value is ignored. - NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color"} + NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "group_visible_calendar_id"} update_data = data.model_dump(exclude_unset=True) for field, value in update_data.items(): if field in NULLABLE_OVERRIDES: diff --git a/backend/tests/test_collaboration.py b/backend/tests/test_collaboration.py index 4f3eb45..aa22346 100644 --- a/backend/tests/test_collaboration.py +++ b/backend/tests/test_collaboration.py @@ -155,8 +155,9 @@ def test_combined_view_marks_owner_and_group_event(client): gid = group["id"] gcal = group["group_calendar_id"] - # Bob's own calendar + event. + # Bob's own calendar + event; Bob designates it as his group-visible calendar. 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") # A group-calendar event. _make_event(client, admin, gcal, "Gruppentermin") @@ -185,6 +186,7 @@ def test_private_visibility_hidden_and_busy(client): gid = group["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}) _make_event(client, b_tok, b_cal, "Geheimes", private=True, start="2026-06-15T10:00:00+00:00", end="2026-06-15T11:00:00+00:00") @@ -204,6 +206,25 @@ def test_private_visibility_hidden_and_busy(client): assert not any(e["start"].startswith("2026-06-15") for e in seen2) +def test_member_calendar_hidden_until_designated(client): + admin = register_admin(client) + b_id, b_tok = create_user(client, admin, "bob") + group = client.post("/api/groups/", headers=auth(admin), + json={"name": "Team", "member_ids": [b_id]}).json() + gid = group["id"] + b_cal = _make_calendar(client, b_tok, "Bobs Kalender") + _make_event(client, b_tok, b_cal, "Bobs Termin") + + # Not designated yet -> admin doesn't see Bob's calendar in the combined view. + seen = _combined_titles(client, admin, gid) + assert not any(e["title"] == "Bobs Termin" for e in seen) + + # After Bob designates it, it appears. + client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal}) + seen2 = _combined_titles(client, admin, gid) + assert any(e["title"] == "Bobs Termin" for e in seen2) + + def test_private_visibility_validation(client): admin = register_admin(client) r = client.put("/api/settings/", headers=auth(admin), json={"private_event_visibility": "bogus"})