Files
Calendarr/backend/routers/groups_router.py
Scarriffle 4b7e5799bf refactor: group icons are semantic keys, not emoji (display_title drops glyph)
Group icons move from OS-emoji (which render differently per platform) to
semantic keys rendered natively per client. The combined view's display_title
therefore no longer embeds an icon glyph — group-calendar events are
distinguished by their colour; only the owner/creator first-name is prefixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 19:20:31 +02:00

449 lines
16 KiB
Python

"""Groups: shared group calendar + combined member-calendar overlay view.
A group has members and exactly one group calendar (a local calendar owned by
the creator, linked via group_calendars). Members get read/write on the group
calendar (enforced by permissions.accessible_local_calendar). The combined view
overlays every member's local calendars plus the group calendar, applying each
member's private-event visibility setting.
"""
import logging
from datetime import datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import or_
from sqlalchemy.orm import Session
import models
from auth import get_current_user
from database import get_db
from local_events_util import build_local_event_dict, expand_recurring_local, mask_busy_event
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:
return datetime.now(timezone.utc).isoformat()
class GroupCreate(BaseModel):
name: str
member_ids: List[int] = []
icon: Optional[str] = None
class GroupUpdate(BaseModel):
name: Optional[str] = None
icon: Optional[str] = None
class MemberAdd(BaseModel):
user_id: int
def _membership(db: Session, group_id: int, user_id: int) -> Optional[models.GroupMember]:
return (
db.query(models.GroupMember)
.filter(
models.GroupMember.group_id == group_id,
models.GroupMember.user_id == user_id,
)
.first()
)
def _require_member(db: Session, group: models.Group, user: models.User) -> models.GroupMember:
m = _membership(db, group.id, user.id)
if not m:
raise HTTPException(403, "You are not a member of this group")
return m
def _require_owner(db: Session, group: models.Group, user: models.User) -> None:
m = _membership(db, group.id, user.id)
if not m or m.role != "owner":
raise HTTPException(403, "Only the group owner may do this")
def _get_group_or_404(db: Session, group_id: int) -> models.Group:
g = db.query(models.Group).filter(models.Group.id == group_id).first()
if not g:
raise HTTPException(404, "Group not found")
return g
def _group_calendar_id(db: Session, group_id: int) -> Optional[int]:
gc = (
db.query(models.GroupCalendar)
.filter(models.GroupCalendar.group_id == group_id)
.first()
)
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,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
group = models.Group(name=data.name, icon=(data.icon or None),
created_by=current_user.id, created_at=_now_iso())
db.add(group)
db.flush()
# Creator is owner; add the requested members (deduped, excluding creator).
# 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",
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(
user_id=current_user.id,
name=f"{data.name} (Gruppe)",
color=PALETTE[group.id % len(PALETTE)],
)
db.add(cal)
db.flush()
db.add(models.GroupCalendar(group_id=group.id, calendar_id=cal.id))
db.commit()
db.refresh(group)
return _group_detail(db, group, current_user)
@router.get("/")
def list_groups(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
memberships = (
db.query(models.GroupMember)
.filter(models.GroupMember.user_id == current_user.id)
.all()
)
out = []
for m in memberships:
group = db.query(models.Group).filter(models.Group.id == m.group_id).first()
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": gcal_id,
"group_calendar_color": _group_calendar_color(db, gcal_id),
})
return out
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 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": gcal_id,
"group_calendar_color": _group_calendar_color(db, gcal_id),
}
@router.put("/{group_id}")
def update_group(
group_id: int,
data: GroupUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
group = _get_group_or_404(db, group_id)
_require_owner(db, group, current_user)
if data.name is not None and data.name.strip():
group.name = data.name.strip()
if data.icon is not None:
group.icon = data.icon or None
db.commit()
return _group_detail(db, group, current_user)
@router.get("/{group_id}")
def get_group(
group_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
group = _get_group_or_404(db, group_id)
_require_member(db, group, current_user)
return _group_detail(db, group, current_user)
@router.post("/{group_id}/members")
def add_member(
group_id: int,
data: MemberAdd,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
group = _get_group_or_404(db, group_id)
_require_owner(db, group, current_user)
if not db.query(models.User).filter(models.User.id == data.user_id).first():
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",
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}
@router.delete("/{group_id}/members/{user_id}")
def remove_member(
group_id: int,
user_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
group = _get_group_or_404(db, group_id)
# Owner can remove anyone; a member may remove themselves (leave).
if user_id != current_user.id:
_require_owner(db, group, current_user)
else:
_require_member(db, group, current_user)
target = _membership(db, group_id, user_id)
if not target:
raise HTTPException(404, "Member not found")
if target.role == "owner":
raise HTTPException(422, "The owner cannot be removed; delete the group instead")
db.delete(target)
db.commit()
return {"ok": True}
@router.delete("/{group_id}")
def delete_group(
group_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
group = _get_group_or_404(db, group_id)
_require_owner(db, group, current_user)
# Remove the group calendar (and its events) too.
gc = db.query(models.GroupCalendar).filter(models.GroupCalendar.group_id == group_id).first()
if gc:
cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == gc.calendar_id).first()
if cal:
db.delete(cal) # cascades to events
db.delete(group) # cascades to members + group_calendar link
db.commit()
return {"ok": True}
def _first_name(name: Optional[str]) -> str:
if not name:
return ""
return name.split(" ", 1)[0]
def _decorate_title(title: str, *, is_group: bool, creator: Optional[dict],
owner: Optional[dict], me_id: int) -> str:
"""Server-side display title for the combined view so every client (web,
iOS, Android) renders identically: another member's / creator's first name
is prefixed. No icon glyph is embedded — group icons are semantic keys the
clients render as native vector icons, and group-calendar events are
distinguished by their (group) colour. The raw `title` stays for editing."""
if is_group:
if creator and creator.get("id") is not None and creator.get("id") != me_id:
return f"{_first_name(creator.get('display_name'))}: {title}"
return title
if owner and owner.get("id") is not None and owner.get("id") != me_id:
return f"{_first_name(owner.get('display_name'))}: {title}"
return title
@router.get("/{group_id}/combined")
def combined_events(
group_id: int,
start: str = Query(...),
end: str = Query(...),
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
group = _get_group_or_404(db, group_id)
_require_member(db, group, current_user)
try:
start_dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
end_dt = datetime.fromisoformat(end.replace("Z", "+00:00"))
except ValueError:
raise HTTPException(400, "Invalid date format — use ISO 8601")
if start_dt.tzinfo is None:
start_dt = start_dt.replace(tzinfo=timezone.utc)
if end_dt.tzinfo is None:
end_dt = end_dt.replace(tzinfo=timezone.utc)
members = db.query(models.GroupMember).filter(models.GroupMember.group_id == group_id).all()
name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()}
visibility_cache: dict[int, str] = {}
def visibility_for(user_id: int) -> str:
if user_id not in visibility_cache:
s = db.query(models.UserSettings).filter(models.UserSettings.user_id == user_id).first()
visibility_cache[user_id] = (s.private_event_visibility if s else None) or "busy"
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):
owner_user = name_cache.get(owner_id)
owner = {"id": owner_id, "display_name": owner_user}
events = (
db.query(models.LocalEvent)
.filter(
models.LocalEvent.calendar_id == cal.id,
or_(
(models.LocalEvent.rrule == None) & (models.LocalEvent.start < end) & (models.LocalEvent.end > start),
models.LocalEvent.rrule != None,
),
)
.all()
)
for ev in events:
creator_owner_id = ev.creator_id or owner_id
# Private filtering for events that belong to someone else.
if ev.is_private and creator_owner_id != current_user.id:
vis = visibility_for(creator_owner_id)
if vis == "hidden":
continue
creator = None
if ev.creator_id and name_cache.get(ev.creator_id):
creator = {"id": ev.creator_id, "display_name": name_cache[ev.creator_id]}
elif ev.creator_name_external:
creator = {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"}
if ev.rrule:
built = expand_recurring_local(ev, cal, start_dt, end_dt, creator=creator, owner=owner, is_group_event=is_group)
else:
built = [build_local_event_dict(ev, cal, rrule=None, creator=creator, owner=owner, is_group_event=is_group)]
for b in built:
if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy":
b = mask_busy_event(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)
# Decorated title (group icon / owner name) computed server-side
# so all clients render identically; raw `title` kept for editing.
b["display_title"] = _decorate_title(
b.get("title", ""), is_group=is_group, creator=b.get("creator"),
owner=owner, me_id=current_user.id,
)
all_events.append(b)
# 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:
settings = (
db.query(models.UserSettings)
.filter(models.UserSettings.user_id == m.user_id)
.first()
)
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.
if group_cal_id is not None:
group_cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == group_cal_id).first()
if group_cal:
emit_calendar(group_cal, group_cal.user_id, is_group=True)
return {"events": all_events}