feat: Kalender-Sharing, Gruppen, iCal Import/Export & Ersteller (Server)
Kollaborations-Features ausschliesslich fuer lokale Kalender:
- Sharing: calendar_shares-Tabelle, GET/POST/DELETE /api/local/calendars/{id}/shares
(nur Besitzer), GET /api/users/directory, geteilte Kalender in
GET /api/local/calendars (shared_by/permission/owned) und im Merge-Read.
- Gruppen: groups/group_members/group_calendars + /api/groups-Router inkl.
kombinierter Ansicht /api/groups/{id}/combined (owner + is_group_event).
- Ersteller: local_events.creator_id (serverseitig gesetzt) + creator_name_external
aus ORGANIZER; creator-Feld in allen lokalen Event-Responses.
- Private-Flag: local_events.is_private + user_settings.private_event_visibility
(hidden|busy), Filterung in der Gruppenansicht.
- iCal Import/Export: ical_io.py, POST /api/local/calendars/{id}/import,
POST /api/local/import, GET /api/local/calendars/{id}/export.
- Zentraler Berechtigungs-Helper (permissions.py) und gemeinsamer Event-Dict-
Builder (local_events_util.py) ersetzen die Nur-Besitzer-Filter.
- pytest-Suite (12 Tests) fuer Sharing, Gruppen, Parser, Private-Filterung.
Additiv & rueckwaertskompatibel; Migrationen in main.py._migrate().
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,26 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Query, UploadFile, File
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import ical_io
|
||||
import models
|
||||
import permissions
|
||||
from auth import get_current_user
|
||||
from database import get_db
|
||||
from local_events_util import build_local_event_dict, resolve_creator
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class CalendarCreate(BaseModel):
|
||||
name: str
|
||||
color: str = "#34a853"
|
||||
@@ -33,6 +42,7 @@ class EventCreate(BaseModel):
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
rrule: Optional[str] = None
|
||||
private: bool = False
|
||||
|
||||
|
||||
class EventUpdate(BaseModel):
|
||||
@@ -45,35 +55,33 @@ class EventUpdate(BaseModel):
|
||||
color: Optional[str] = None
|
||||
rrule: Optional[str] = None
|
||||
exdate: Optional[str] = None
|
||||
private: Optional[bool] = None
|
||||
|
||||
|
||||
def _cal_dict(cal: models.LocalCalendar) -> dict:
|
||||
return {
|
||||
class ShareCreate(BaseModel):
|
||||
user_id: int
|
||||
permission: str = "read"
|
||||
|
||||
|
||||
def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
|
||||
shared_by: Optional[str] = None, permission: Optional[str] = None) -> dict:
|
||||
d = {
|
||||
"id": cal.id,
|
||||
"name": cal.name,
|
||||
"color": cal.color,
|
||||
"enabled": cal.enabled,
|
||||
"type": "local",
|
||||
"owned": owned,
|
||||
}
|
||||
if shared_by is not None:
|
||||
d["shared_by"] = shared_by
|
||||
if permission is not None:
|
||||
d["permission"] = permission
|
||||
return d
|
||||
|
||||
|
||||
def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar) -> dict:
|
||||
return {
|
||||
"id": ev.uid,
|
||||
"url": f"local://{ev.uid}",
|
||||
"title": ev.title,
|
||||
"start": ev.start,
|
||||
"end": ev.end,
|
||||
"allDay": ev.all_day,
|
||||
"location": ev.location or "",
|
||||
"description": ev.description or "",
|
||||
"color": ev.color,
|
||||
"rrule": ev.rrule,
|
||||
"exdate": ev.exdate,
|
||||
"calendar_id": f"local-{cal.id}",
|
||||
"calendar_name": cal.name,
|
||||
"calendarColor": cal.color,
|
||||
"source": "local",
|
||||
}
|
||||
def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar, db: Session) -> dict:
|
||||
return build_local_event_dict(ev, cal, creator=resolve_creator(ev))
|
||||
|
||||
|
||||
# ── Calendar CRUD ─────────────────────────────────────────
|
||||
@@ -83,12 +91,31 @@ def list_calendars(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
cals = (
|
||||
# Own calendars
|
||||
own = (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(models.LocalCalendar.user_id == current_user.id)
|
||||
.all()
|
||||
)
|
||||
return [_cal_dict(c) for c in cals]
|
||||
result = [_cal_dict(c, owned=True) for c in own]
|
||||
|
||||
# Calendars shared with this user
|
||||
shares = (
|
||||
db.query(models.CalendarShare)
|
||||
.filter(models.CalendarShare.user_id == current_user.id)
|
||||
.all()
|
||||
)
|
||||
for share in shares:
|
||||
cal = share.calendar
|
||||
if cal is None:
|
||||
continue
|
||||
owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
|
||||
result.append(_cal_dict(
|
||||
cal, owned=False,
|
||||
shared_by=owner.username if owner else None,
|
||||
permission=share.permission,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/calendars")
|
||||
@@ -164,16 +191,10 @@ def create_event(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
cal = (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalCalendar.id == data.calendar_id,
|
||||
models.LocalCalendar.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
# Owner, shared (read_write), or group-member calendars are writable.
|
||||
cal = permissions.accessible_local_calendar(
|
||||
db, current_user, data.calendar_id, require_write=True
|
||||
)
|
||||
if not cal:
|
||||
raise HTTPException(404, "Calendar not found")
|
||||
|
||||
ev = models.LocalEvent(
|
||||
calendar_id=cal.id,
|
||||
@@ -186,11 +207,22 @@ def create_event(
|
||||
description=data.description,
|
||||
color=data.color,
|
||||
rrule=data.rrule,
|
||||
is_private=data.private,
|
||||
creator_id=current_user.id, # server-side, never from the client
|
||||
)
|
||||
db.add(ev)
|
||||
db.commit()
|
||||
db.refresh(ev)
|
||||
return _event_dict(ev, cal)
|
||||
return _event_dict(ev, cal, db)
|
||||
|
||||
|
||||
def _writable_event(db: Session, current_user: models.User, uid: str) -> models.LocalEvent:
|
||||
ev = db.query(models.LocalEvent).filter(models.LocalEvent.uid == uid).first()
|
||||
if not ev:
|
||||
raise HTTPException(404, "Event not found")
|
||||
# Raises 404/403 unless the user may write this event's calendar.
|
||||
permissions.accessible_local_calendar(db, current_user, ev.calendar_id, require_write=True)
|
||||
return ev
|
||||
|
||||
|
||||
@router.put("/events/{uid}")
|
||||
@@ -200,17 +232,9 @@ def update_event(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
ev = (
|
||||
db.query(models.LocalEvent)
|
||||
.join(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalEvent.uid == uid,
|
||||
models.LocalCalendar.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not ev:
|
||||
raise HTTPException(404, "Event not found")
|
||||
ev = _writable_event(db, current_user, uid)
|
||||
if data.private is not None:
|
||||
ev.is_private = data.private
|
||||
if data.title is not None:
|
||||
ev.title = data.title
|
||||
if data.start is not None:
|
||||
@@ -243,17 +267,194 @@ def delete_event(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
ev = (
|
||||
db.query(models.LocalEvent)
|
||||
.join(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalEvent.uid == uid,
|
||||
models.LocalCalendar.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not ev:
|
||||
raise HTTPException(404, "Event not found")
|
||||
ev = _writable_event(db, current_user, uid)
|
||||
db.delete(ev)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Sharing (owner only) ──────────────────────────────────
|
||||
|
||||
@router.get("/calendars/{calendar_id}/shares")
|
||||
def list_shares(
|
||||
calendar_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
permissions.is_calendar_owner(db, current_user, calendar_id)
|
||||
shares = (
|
||||
db.query(models.CalendarShare)
|
||||
.filter(models.CalendarShare.calendar_id == calendar_id)
|
||||
.all()
|
||||
)
|
||||
out = []
|
||||
for s in shares:
|
||||
u = db.query(models.User).filter(models.User.id == s.user_id).first()
|
||||
out.append({
|
||||
"user_id": s.user_id,
|
||||
"display_name": u.username if u else None,
|
||||
"permission": s.permission,
|
||||
"created_at": s.created_at,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/calendars/{calendar_id}/shares")
|
||||
def add_share(
|
||||
calendar_id: int,
|
||||
data: ShareCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
permissions.is_calendar_owner(db, current_user, calendar_id)
|
||||
if data.permission not in ("read", "read_write"):
|
||||
raise HTTPException(422, "permission must be 'read' or 'read_write'")
|
||||
target = db.query(models.User).filter(models.User.id == data.user_id).first()
|
||||
if not target:
|
||||
raise HTTPException(404, "User not found")
|
||||
if target.id == current_user.id:
|
||||
raise HTTPException(422, "Cannot share a calendar with yourself")
|
||||
|
||||
share = (
|
||||
db.query(models.CalendarShare)
|
||||
.filter(
|
||||
models.CalendarShare.calendar_id == calendar_id,
|
||||
models.CalendarShare.user_id == data.user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if share:
|
||||
share.permission = data.permission # update existing
|
||||
else:
|
||||
share = models.CalendarShare(
|
||||
calendar_id=calendar_id,
|
||||
user_id=data.user_id,
|
||||
permission=data.permission,
|
||||
created_at=_now_iso(),
|
||||
)
|
||||
db.add(share)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/calendars/{calendar_id}/shares/{user_id}")
|
||||
def remove_share(
|
||||
calendar_id: int,
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
permissions.is_calendar_owner(db, current_user, calendar_id)
|
||||
share = (
|
||||
db.query(models.CalendarShare)
|
||||
.filter(
|
||||
models.CalendarShare.calendar_id == calendar_id,
|
||||
models.CalendarShare.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not share:
|
||||
raise HTTPException(404, "Share not found")
|
||||
db.delete(share)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── iCal Import / Export (local calendars only) ───────────
|
||||
|
||||
def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict:
|
||||
parsed = ical_io.parse_ics(raw)
|
||||
imported = 0
|
||||
skipped = 0
|
||||
for item in parsed["events"]:
|
||||
uid = item.get("uid") or str(uuid.uuid4())
|
||||
existing = db.query(models.LocalEvent).filter(models.LocalEvent.uid == uid).first()
|
||||
if existing:
|
||||
skipped += 1
|
||||
continue
|
||||
ev = models.LocalEvent(
|
||||
calendar_id=cal.id,
|
||||
uid=uid,
|
||||
title=item.get("title") or "(ohne Titel)",
|
||||
start=item["start"],
|
||||
end=item["end"],
|
||||
all_day=item.get("all_day", False),
|
||||
location=item.get("location"),
|
||||
description=item.get("description"),
|
||||
rrule=item.get("rrule"),
|
||||
exdate=item.get("exdate"),
|
||||
creator_name_external=item.get("organizer"),
|
||||
)
|
||||
db.add(ev)
|
||||
imported += 1
|
||||
db.commit()
|
||||
return {"imported": imported, "skipped": skipped, "errors": parsed["errors"]}
|
||||
|
||||
|
||||
@router.post("/calendars/{calendar_id}/import")
|
||||
async def import_calendar(
|
||||
calendar_id: int,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True)
|
||||
raw = await file.read()
|
||||
try:
|
||||
return _import_ics_into(cal, raw, db)
|
||||
except ValueError as e:
|
||||
raise HTTPException(422, str(e))
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def import_generic(
|
||||
file: UploadFile = File(...),
|
||||
calendar_id: Optional[int] = Form(None),
|
||||
create_calendar: bool = Form(False),
|
||||
calendar_name: Optional[str] = Form(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
if create_calendar:
|
||||
cal = models.LocalCalendar(
|
||||
user_id=current_user.id,
|
||||
name=calendar_name or "Importiert",
|
||||
)
|
||||
db.add(cal)
|
||||
db.commit()
|
||||
db.refresh(cal)
|
||||
elif calendar_id is not None:
|
||||
cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True)
|
||||
else:
|
||||
raise HTTPException(422, "Provide calendar_id or create_calendar=true")
|
||||
|
||||
raw = await file.read()
|
||||
try:
|
||||
result = _import_ics_into(cal, raw, db)
|
||||
except ValueError as e:
|
||||
raise HTTPException(422, str(e))
|
||||
result["calendar_id"] = cal.id
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/calendars/{calendar_id}/export")
|
||||
def export_calendar(
|
||||
calendar_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
cal = permissions.accessible_local_calendar(db, current_user, calendar_id)
|
||||
events = (
|
||||
db.query(models.LocalEvent)
|
||||
.filter(models.LocalEvent.calendar_id == cal.id)
|
||||
.all()
|
||||
)
|
||||
# Resolve creator display names for ORGANIZER.
|
||||
name_cache = {u.id: u.username for u in db.query(models.User).all()}
|
||||
ics = ical_io.build_ics(cal, events, name_cache=name_cache)
|
||||
safe_name = "".join(c for c in cal.name if c.isalnum() or c in (" ", "-", "_")).strip() or "calendar"
|
||||
return Response(
|
||||
content=ics,
|
||||
media_type="text/calendar",
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}.ics"'},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user