feat: Gruppen-Sichtbarkeit – genau ein designierter Kalender pro Person
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 <noreply@anthropic.com>
This commit is contained in:
@@ -157,6 +157,12 @@ def _migrate():
|
|||||||
logging.info("Migration: added is_private to local_events")
|
logging.info("Migration: added is_private to local_events")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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()
|
_migrate()
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ class UserSettings(Base):
|
|||||||
# How this user's private events appear to other group members:
|
# How this user's private events appear to other group members:
|
||||||
# 'hidden' = invisible, 'busy' = anonymous busy block (default).
|
# 'hidden' = invisible, 'busy' = anonymous busy block (default).
|
||||||
private_event_visibility = Column(String(10), default="busy")
|
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")
|
user = relationship("User", back_populates="settings")
|
||||||
|
|
||||||
|
|||||||
@@ -313,16 +313,26 @@ def combined_events(
|
|||||||
b = _strip_busy(b)
|
b = _strip_busy(b)
|
||||||
all_events.append(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:
|
for m in members:
|
||||||
member_cals = (
|
settings = (
|
||||||
db.query(models.LocalCalendar)
|
db.query(models.UserSettings)
|
||||||
.filter(models.LocalCalendar.user_id == m.user_id)
|
.filter(models.UserSettings.user_id == m.user_id)
|
||||||
.all()
|
.first()
|
||||||
)
|
)
|
||||||
for cal in member_cals:
|
visible_id = settings.group_visible_calendar_id if settings else None
|
||||||
if group_cal_id is not None and cal.id == group_cal_id:
|
if visible_id is None or visible_id == group_cal_id:
|
||||||
continue
|
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)
|
emit_calendar(cal, m.user_id, is_group=False)
|
||||||
|
|
||||||
# The group calendar itself.
|
# The group calendar itself.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class SettingsUpdate(BaseModel):
|
|||||||
line_color: Optional[str] = None
|
line_color: Optional[str] = None
|
||||||
bg_color: Optional[str] = None
|
bg_color: Optional[str] = None
|
||||||
private_event_visibility: Optional[str] = None
|
private_event_visibility: Optional[str] = None
|
||||||
|
group_visible_calendar_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
def _settings_dict(s: models.UserSettings) -> dict:
|
def _settings_dict(s: models.UserSettings) -> dict:
|
||||||
@@ -48,6 +49,7 @@ def _settings_dict(s: models.UserSettings) -> dict:
|
|||||||
"line_color": s.line_color,
|
"line_color": s.line_color,
|
||||||
"bg_color": s.bg_color,
|
"bg_color": s.bg_color,
|
||||||
"private_event_visibility": s.private_event_visibility or "busy",
|
"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
|
# For these three override colours, an explicit null is meaningful
|
||||||
# ("reset to default") and must be persisted as NULL. All other fields
|
# ("reset to default") and must be persisted as NULL. All other fields
|
||||||
# keep the previous behaviour where a null/missing value is ignored.
|
# 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)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
if field in NULLABLE_OVERRIDES:
|
if field in NULLABLE_OVERRIDES:
|
||||||
|
|||||||
@@ -155,8 +155,9 @@ def test_combined_view_marks_owner_and_group_event(client):
|
|||||||
gid = group["id"]
|
gid = group["id"]
|
||||||
gcal = group["group_calendar_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")
|
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")
|
_make_event(client, b_tok, b_cal, "Bobs Termin")
|
||||||
# A group-calendar event.
|
# A group-calendar event.
|
||||||
_make_event(client, admin, gcal, "Gruppentermin")
|
_make_event(client, admin, gcal, "Gruppentermin")
|
||||||
@@ -185,6 +186,7 @@ def test_private_visibility_hidden_and_busy(client):
|
|||||||
gid = group["id"]
|
gid = group["id"]
|
||||||
|
|
||||||
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
|
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,
|
_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")
|
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)
|
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):
|
def test_private_visibility_validation(client):
|
||||||
admin = register_admin(client)
|
admin = register_admin(client)
|
||||||
r = client.put("/api/settings/", headers=auth(admin), json={"private_event_visibility": "bogus"})
|
r = client.put("/api/settings/", headers=auth(admin), json={"private_event_visibility": "bogus"})
|
||||||
|
|||||||
Reference in New Issue
Block a user