feat: server-definierte Gruppenfarben (per API) + Gruppentermine überall erstellen

- 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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-05-31 18:52:40 +02:00
parent b0f1497bc8
commit a992d97796
8 changed files with 150 additions and 14 deletions

View File

@@ -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()

View File

@@ -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")

View File

@@ -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

View File

@@ -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")

View File

@@ -386,6 +386,10 @@
<label data-i18n="group_members">Mitglieder</label>
<div id="group-member-picker" class="share-user-picker"></div>
</div>
<div class="form-group" id="group-colors-group" style="display:none">
<label data-i18n="group_member_colors">Farben der Mitglieder</label>
<div id="group-member-colors"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-danger hidden" id="group-delete" data-i18n="group_delete">Gruppe löschen</button>

View File

@@ -299,17 +299,16 @@ async function fetchAndRender(force = false, silent = false) {
// cryptic initials); your own events stay unprefixed. Colour-code by
// owner so each member reads as a group.
let title = ev.title;
let color;
if (ev.is_group_event) {
// Group calendar: everyone adds; mark who added it.
const cName = ev.creator && ev.creator.display_name;
const cId = ev.creator && ev.creator.id;
title = (cName && cId !== me.id) ? `👥 ${firstName(cName)}: ${ev.title}` : `👥 ${ev.title}`;
color = ownerColor(cId) || ev.color;
} else {
if (ownerName && !isMine) title = `${firstName(ownerName)}: ${ev.title}`;
color = ownerColor(ownerId) || ev.color;
} else if (ownerName && !isMine) {
title = `${firstName(ownerName)}: ${ev.title}`;
}
// Server-defined colour (member colour / group colour) so web + apps match.
const color = ev.display_color || ownerColor(ownerId) || ev.color;
return { ...ev, title, color };
});
eventCache.start = null; eventCache.end = null; // invalidate normal cache
@@ -1429,11 +1428,12 @@ function populateCalendarSelect(selectedId) {
sel.appendChild(opt);
});
});
// Local calendars
// Local calendars (group calendars marked with 👥 so group events can be
// created from anywhere, not just the group view).
state.localCalendars.filter(c => !c.sidebar_hidden).forEach(cal => {
const opt = document.createElement('option');
opt.value = `local-${cal.id}`;
opt.textContent = cal.name;
opt.textContent = cal.group ? `👥 ${cal.name}` : cal.name;
if (`local-${cal.id}` === selectedId) opt.selected = true;
sel.appendChild(opt);
});
@@ -2358,9 +2358,41 @@ async function openGroupModal(groupId) {
modal.__memberIds = new Set([...existingMemberIds].filter(id => id !== me.id));
renderGroupMemberPicker();
// Member colours (edit mode only): owner can recolour any member.
const colorsGroup = document.getElementById('group-colors-group');
if (isEdit && detail) {
colorsGroup.style.display = '';
renderGroupMemberColors(groupId, detail.members || []);
} else {
colorsGroup.style.display = 'none';
}
openModal('modal-group');
}
function renderGroupMemberColors(groupId, members) {
const el = document.getElementById('group-member-colors');
if (!el) return;
el.innerHTML = members.map(m =>
`<div class="pick-row" style="cursor:default">
<span class="cal-item-dot group-color-dot" data-uid="${m.id}" data-color="${m.color || '#4285f4'}"
style="background:${m.color || '#4285f4'};cursor:pointer" title="${t('change_color')}"></span>
<span class="pick-name">${escHtml(m.display_name || '')}</span>
</div>`
).join('');
el.querySelectorAll('.group-color-dot').forEach(dot => {
dot.addEventListener('click', async () => {
const picked = await openColorPicker(dot, dot.dataset.color);
if (!picked) return;
try {
await api.put(`/groups/${groupId}/members/${dot.dataset.uid}/color`, { color: picked });
dot.style.background = picked;
dot.dataset.color = picked;
} catch (e) { showToast(e.message, true); }
});
});
}
const GROUP_ICONS = ['👥', '👨‍👩‍👧', '🏠', '❤️', '🧑‍🤝‍🧑', '⚽', '🎓', '💼', '🎉', '🐶', '✈️', '🎵', '🍕', '📚', '🌳', '⭐'];
function renderGroupIconPicker() {
const modal = document.getElementById('modal-group');

View File

@@ -124,6 +124,7 @@ const translations = {
group_icon: 'Icon',
group_visible_flag: 'Für deine Gruppen sichtbar',
group_new_event: '+ Gruppentermin',
group_member_colors: 'Farben der Mitglieder',
only_owner_color: 'Nur der Besitzer kann die Farbe ändern',
upload_too_large: 'Datei zu groß (Server-Limit). Bitte Upload-Limit erhöhen.',
shared_with_me: 'Mit dir geteilt',
@@ -392,6 +393,7 @@ const translations = {
group_icon: 'Icon',
group_visible_flag: 'Visible to your groups',
group_new_event: '+ Group event',
group_member_colors: 'Member colours',
only_owner_color: 'Only the owner can change the colour',
upload_too_large: 'File too large (server limit). Please raise the upload limit.',
shared_with_me: 'Shared with me',

View File

@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change
export const APP_VERSION = 'v36';
export const APP_VERSION = 'v37';