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:
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Increment APP_VERSION with every code change
|
||||
export const APP_VERSION = 'v36';
|
||||
export const APP_VERSION = 'v37';
|
||||
|
||||
Reference in New Issue
Block a user