From a992d977967351287483bbad6808508d353045e2 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 31 May 2026 18:52:40 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20server-definierte=20Gruppenfarben=20(pe?= =?UTF-8?q?r=20API)=20+=20Gruppentermine=20=C3=BCberall=20erstellen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pro Mitglied eine Farbe (group_members.color, auto aus Palette, vom Owner oder Mitglied selbst änderbar via PUT /groups/{id}/members/{uid}/color). - Gruppentermin-Farbe = Farbe des Gruppenkalenders. - API liefert Farben aus: GET /groups & /groups/{id} (member.color, group_calendar_color), GET /groups/{id}/combined (display_color pro Event) -> Apps können dieselben Farben anzeigen. Test ergänzt (18 grün). - Web nutzt display_color; Gruppenkalender im Termin-Editor mit 👥 markiert (Gruppentermine ohne Gruppenansicht erstellbar); Mitglieder-Farben im Verwalten-Dialog editierbar. Version v37. Co-Authored-By: Claude Opus 4.8 --- backend/main.py | 6 +++ backend/models.py | 1 + backend/routers/groups_router.py | 72 ++++++++++++++++++++++++++--- backend/tests/test_collaboration.py | 31 +++++++++++++ frontend/index.html | 4 ++ frontend/js/calendar.js | 46 +++++++++++++++--- frontend/js/i18n.js | 2 + frontend/js/version.js | 2 +- 8 files changed, 150 insertions(+), 14 deletions(-) diff --git a/backend/main.py b/backend/main.py index 2ce442c..013c9d4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -181,6 +181,12 @@ def _migrate(): logging.info("Migration: added icon to groups") except Exception: pass + try: + conn.execute(text("ALTER TABLE group_members ADD COLUMN color VARCHAR(7)")) + conn.commit() + logging.info("Migration: added color to group_members") + except Exception: + pass _migrate() diff --git a/backend/models.py b/backend/models.py index d515637..74184a5 100644 --- a/backend/models.py +++ b/backend/models.py @@ -291,6 +291,7 @@ class GroupMember(Base): group_id = Column(Integer, ForeignKey("groups.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) role = Column(String(10), default="member") # 'owner' | 'member' + color = Column(String(7), nullable=True) # this member's colour within the group joined_at = Column(String(50), nullable=True) # ISO 8601 group = relationship("Group", back_populates="members") diff --git a/backend/routers/groups_router.py b/backend/routers/groups_router.py index 73fc163..47adc04 100644 --- a/backend/routers/groups_router.py +++ b/backend/routers/groups_router.py @@ -25,6 +25,13 @@ logger = logging.getLogger(__name__) router = APIRouter() PALETTE = ["#4285f4", "#ea4335", "#fbbc04", "#34a853", "#ff6d00", "#46bdc6", "#8e24aa"] +# Distinct per-member colours (server-defined so every client shows the same). +MEMBER_PALETTE = ["#4285f4", "#ea4335", "#34a853", "#fbbc05", "#9c27b0", "#ff7043", "#46bdc6", "#7090c0"] + + +def _next_member_color(db: Session, group_id: int) -> str: + n = db.query(models.GroupMember).filter(models.GroupMember.group_id == group_id).count() + return MEMBER_PALETTE[n % len(MEMBER_PALETTE)] def _now_iso() -> str: @@ -86,6 +93,13 @@ def _group_calendar_id(db: Session, group_id: int) -> Optional[int]: return gc.calendar_id if gc else None +def _group_calendar_color(db: Session, calendar_id: Optional[int]) -> Optional[str]: + if calendar_id is None: + return None + cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == calendar_id).first() + return cal.color if cal else None + + @router.post("/") def create_group( data: GroupCreate, @@ -98,15 +112,20 @@ def create_group( db.flush() # Creator is owner; add the requested members (deduped, excluding creator). - db.add(models.GroupMember(group_id=group.id, user_id=current_user.id, role="owner", joined_at=_now_iso())) + # Each member gets a distinct colour from the palette by join order. + db.add(models.GroupMember(group_id=group.id, user_id=current_user.id, role="owner", + color=MEMBER_PALETTE[0], joined_at=_now_iso())) seen = {current_user.id} + idx = 1 for uid in data.member_ids: if uid in seen: continue if not db.query(models.User).filter(models.User.id == uid).first(): continue - db.add(models.GroupMember(group_id=group.id, user_id=uid, role="member", joined_at=_now_iso())) + db.add(models.GroupMember(group_id=group.id, user_id=uid, role="member", + color=MEMBER_PALETTE[idx % len(MEMBER_PALETTE)], joined_at=_now_iso())) seen.add(uid) + idx += 1 # Auto-create the group calendar (a local calendar owned by the creator). cal = models.LocalCalendar( @@ -139,13 +158,15 @@ def list_groups( if not group: continue member_count = db.query(models.GroupMember).filter(models.GroupMember.group_id == group.id).count() + gcal_id = _group_calendar_id(db, group.id) out.append({ "id": group.id, "name": group.name, "icon": group.icon, "role": m.role, "member_count": member_count, - "group_calendar_id": _group_calendar_id(db, group.id), + "group_calendar_id": gcal_id, + "group_calendar_color": _group_calendar_color(db, gcal_id), }) return out @@ -153,20 +174,23 @@ def list_groups( def _group_detail(db: Session, group: models.Group, current_user: models.User) -> dict: members = db.query(models.GroupMember).filter(models.GroupMember.group_id == group.id).all() member_dicts = [] - for m in members: + for i, m in enumerate(members): u = db.query(models.User).filter(models.User.id == m.user_id).first() 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)], }) + gcal_id = _group_calendar_id(db, group.id) return { "id": group.id, "name": group.name, "icon": group.icon, "created_by": group.created_by, "members": member_dicts, - "group_calendar_id": _group_calendar_id(db, group.id), + "group_calendar_id": gcal_id, + "group_calendar_color": _group_calendar_color(db, gcal_id), } @@ -211,7 +235,34 @@ def add_member( raise HTTPException(404, "User not found") if _membership(db, group_id, data.user_id): return {"ok": True} # already a member - db.add(models.GroupMember(group_id=group_id, user_id=data.user_id, role="member", joined_at=_now_iso())) + db.add(models.GroupMember(group_id=group_id, user_id=data.user_id, role="member", + color=_next_member_color(db, group_id), joined_at=_now_iso())) + db.commit() + return {"ok": True} + + +class MemberColorUpdate(BaseModel): + color: str + + +@router.put("/{group_id}/members/{user_id}/color") +def set_member_color( + group_id: int, + user_id: int, + data: MemberColorUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + group = _get_group_or_404(db, group_id) + # Owner may recolour anyone; a member may recolour themselves. + if user_id != current_user.id: + _require_owner(db, group, current_user) + else: + _require_member(db, group, current_user) + m = _membership(db, group_id, user_id) + if not m: + raise HTTPException(404, "Member not found") + m.color = data.color db.commit() return {"ok": True} @@ -300,6 +351,12 @@ def combined_events( return visibility_cache[user_id] group_cal_id = _group_calendar_id(db, group_id) + group_cal_color = _group_calendar_color(db, group_cal_id) + # Server-defined colours so every client renders members/group consistently. + member_color = { + m.user_id: (m.color or MEMBER_PALETTE[i % len(MEMBER_PALETTE)]) + for i, m in enumerate(members) + } all_events: list[dict] = [] def emit_calendar(cal: models.LocalCalendar, owner_id: int, is_group: bool): @@ -337,6 +394,9 @@ def combined_events( for b in built: if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy": b = _strip_busy(b) + # Colour to render with: the group calendar's colour for group + # events, otherwise the owning member's group colour. + b["display_color"] = group_cal_color if is_group else member_color.get(owner_id) all_events.append(b) # Each member shares exactly one calendar into their groups, chosen in their diff --git a/backend/tests/test_collaboration.py b/backend/tests/test_collaboration.py index a44d0a3..2f2a89c 100644 --- a/backend/tests/test_collaboration.py +++ b/backend/tests/test_collaboration.py @@ -134,6 +134,37 @@ def test_group_members_can_write_group_calendar(client): assert r.status_code == 200, r.text +def test_group_member_colors_and_display_color(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"] + gcal = group["group_calendar_id"] + + # Each member has a server-assigned colour; the group exposes its calendar colour. + detail = client.get(f"/api/groups/{gid}", headers=auth(admin)).json() + assert all(m.get("color") for m in detail["members"]) + assert detail.get("group_calendar_color") + + # Owner can recolour a member. + r = client.put(f"/api/groups/{gid}/members/{b_id}/color", headers=auth(admin), + json={"color": "#123456"}) + assert r.status_code == 200, r.text + detail2 = client.get(f"/api/groups/{gid}", headers=auth(admin)).json() + assert any(m["id"] == b_id and m["color"] == "#123456" for m in detail2["members"]) + + # Bob shares a calendar with an event; combined events carry display_color. + 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, admin, gcal, "Gruppentermin") + evs = client.get(f"/api/groups/{gid}/combined", headers=auth(admin), params=RANGE).json()["events"] + by_title = {e["title"]: e for e in evs} + assert by_title["Bobs Termin"]["display_color"] == "#123456" # Bob's member colour + assert by_title["Gruppentermin"]["display_color"] == detail2["group_calendar_color"] + + def test_group_calendar_listed_for_member(client): admin = register_admin(client) b_id, b_tok = create_user(client, admin, "bob") diff --git a/frontend/index.html b/frontend/index.html index 5741d45..a8c78b9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -386,6 +386,10 @@ +