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:
@@ -11,8 +11,10 @@ from sqlalchemy import or_
|
||||
|
||||
import caldav_client
|
||||
import models
|
||||
import permissions
|
||||
from auth import get_current_user
|
||||
from database import get_db
|
||||
from local_events_util import build_local_event_dict, expand_recurring_local, resolve_creator
|
||||
from routers.ical_router import _refresh_if_needed, get_events_for_subscription
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -82,101 +84,6 @@ def _account_dict(a: models.CalDAVAccount) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _expand_recurring_local(ev, local_cal, range_start, range_end):
|
||||
"""Expand a recurring LocalEvent into individual occurrences within the date range."""
|
||||
results = []
|
||||
# Parse excluded dates
|
||||
excluded = set()
|
||||
if ev.exdate:
|
||||
for d in ev.exdate.split(","):
|
||||
d = d.strip()
|
||||
if d:
|
||||
excluded.add(d)
|
||||
try:
|
||||
ev_start_str = ev.start.replace("Z", "+00:00")
|
||||
ev_end_str = ev.end.replace("Z", "+00:00")
|
||||
|
||||
if ev.all_day:
|
||||
ev_start = dt_date.fromisoformat(ev_start_str[:10])
|
||||
ev_end = dt_date.fromisoformat(ev_end_str[:10])
|
||||
duration = ev_end - ev_start
|
||||
rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=dt_datetime.combine(ev_start, dt_datetime.min.time()))
|
||||
r_start = dt_datetime.combine(range_start if isinstance(range_start, dt_date) else range_start.date(), dt_datetime.min.time())
|
||||
r_end = dt_datetime.combine(range_end if isinstance(range_end, dt_date) else range_end.date(), dt_datetime.min.time())
|
||||
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
|
||||
for occ in occurrences:
|
||||
occ_start = occ.date()
|
||||
occ_key = occ_start.strftime("%Y%m%d")
|
||||
if occ_key in excluded:
|
||||
continue
|
||||
occ_end = occ_start + duration
|
||||
results.append({
|
||||
"id": ev.uid,
|
||||
"url": f"local://{ev.uid}",
|
||||
"title": ev.title,
|
||||
"start": occ_start.isoformat(),
|
||||
"end": occ_end.isoformat(),
|
||||
"allDay": True,
|
||||
"location": ev.location or "",
|
||||
"description": ev.description or "",
|
||||
"color": ev.color,
|
||||
"rrule": ev.rrule,
|
||||
"calendar_id": f"local-{local_cal.id}",
|
||||
"calendar_name": local_cal.name,
|
||||
"calendarColor": local_cal.color,
|
||||
"source": "local",
|
||||
})
|
||||
else:
|
||||
ev_start = dt_datetime.fromisoformat(ev_start_str)
|
||||
ev_end = dt_datetime.fromisoformat(ev_end_str)
|
||||
if ev_start.tzinfo is None:
|
||||
ev_start = ev_start.replace(tzinfo=dt_timezone.utc)
|
||||
if ev_end.tzinfo is None:
|
||||
ev_end = ev_end.replace(tzinfo=dt_timezone.utc)
|
||||
duration = ev_end - ev_start
|
||||
rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=ev_start)
|
||||
r_start = range_start if isinstance(range_start, dt_datetime) else dt_datetime.combine(range_start, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
|
||||
r_end = range_end if isinstance(range_end, dt_datetime) else dt_datetime.combine(range_end, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
|
||||
if r_start.tzinfo is None:
|
||||
r_start = r_start.replace(tzinfo=dt_timezone.utc)
|
||||
if r_end.tzinfo is None:
|
||||
r_end = r_end.replace(tzinfo=dt_timezone.utc)
|
||||
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
|
||||
for occ in occurrences:
|
||||
occ_key = occ.strftime("%Y%m%d")
|
||||
if occ_key in excluded:
|
||||
continue
|
||||
occ_end = occ + duration
|
||||
results.append({
|
||||
"id": ev.uid,
|
||||
"url": f"local://{ev.uid}",
|
||||
"title": ev.title,
|
||||
"start": occ.isoformat(),
|
||||
"end": occ_end.isoformat(),
|
||||
"allDay": False,
|
||||
"location": ev.location or "",
|
||||
"description": ev.description or "",
|
||||
"color": ev.color,
|
||||
"rrule": ev.rrule,
|
||||
"calendar_id": f"local-{local_cal.id}",
|
||||
"calendar_name": local_cal.name,
|
||||
"calendarColor": local_cal.color,
|
||||
"source": "local",
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.warning("Error expanding recurring event %s: %s", ev.uid, exc)
|
||||
# Fall back to single event
|
||||
results.append({
|
||||
"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,
|
||||
"calendar_id": f"local-{local_cal.id}", "calendar_name": local_cal.name,
|
||||
"calendarColor": local_cal.color, "source": "local",
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def _normalize_url(url: str) -> str:
|
||||
"""Normalize URL for comparison: lowercase scheme/host, strip trailing slash."""
|
||||
parsed = urlparse(url)
|
||||
@@ -417,15 +324,17 @@ def get_events(
|
||||
"Error fetching calendar %s: %s", calendar.id, exc
|
||||
)
|
||||
|
||||
# ── Local calendar events ─────────────────────────────
|
||||
# ── Local calendar events (own + shared + group calendars) ─────────────
|
||||
readable_ids = permissions.readable_local_calendar_ids(db, current_user)
|
||||
local_calendars = (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalCalendar.user_id == current_user.id,
|
||||
models.LocalCalendar.id.in_(readable_ids),
|
||||
models.LocalCalendar.enabled == True,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
) if readable_ids else []
|
||||
name_cache = {u.id: u.username for u in db.query(models.User).all()}
|
||||
for local_cal in local_calendars:
|
||||
local_events = (
|
||||
db.query(models.LocalEvent)
|
||||
@@ -441,25 +350,11 @@ def get_events(
|
||||
.all()
|
||||
)
|
||||
for ev in local_events:
|
||||
creator = resolve_creator(ev, name_cache=name_cache)
|
||||
if ev.rrule:
|
||||
all_events.extend(_expand_recurring_local(ev, local_cal, start_dt, end_dt))
|
||||
all_events.extend(expand_recurring_local(ev, local_cal, start_dt, end_dt, creator=creator))
|
||||
else:
|
||||
all_events.append({
|
||||
"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": None,
|
||||
"calendar_id": f"local-{local_cal.id}",
|
||||
"calendar_name": local_cal.name,
|
||||
"calendarColor": local_cal.color,
|
||||
"source": "local",
|
||||
})
|
||||
all_events.append(build_local_event_dict(ev, local_cal, rrule=None, creator=creator))
|
||||
|
||||
# ── iCal subscription events ──────────────────────────
|
||||
ical_subs = (
|
||||
|
||||
334
backend/routers/groups_router.py
Normal file
334
backend/routers/groups_router.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
PALETTE = ["#4285f4", "#ea4335", "#fbbc04", "#34a853", "#ff6d00", "#46bdc6", "#8e24aa"]
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class GroupCreate(BaseModel):
|
||||
name: str
|
||||
member_ids: List[int] = []
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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, created_by=current_user.id, created_at=_now_iso())
|
||||
db.add(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()))
|
||||
seen = {current_user.id}
|
||||
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()))
|
||||
seen.add(uid)
|
||||
|
||||
# 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()
|
||||
out.append({
|
||||
"id": group.id,
|
||||
"name": group.name,
|
||||
"role": m.role,
|
||||
"member_count": member_count,
|
||||
"group_calendar_id": _group_calendar_id(db, group.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 m in members:
|
||||
u = db.query(models.User).filter(models.User.id == m.user_id).first()
|
||||
member_dicts.append({
|
||||
"id": m.user_id,
|
||||
"display_name": u.username if u else None,
|
||||
"role": m.role,
|
||||
})
|
||||
return {
|
||||
"id": group.id,
|
||||
"name": group.name,
|
||||
"created_by": group.created_by,
|
||||
"members": member_dicts,
|
||||
"group_calendar_id": _group_calendar_id(db, group.id),
|
||||
}
|
||||
|
||||
|
||||
@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", joined_at=_now_iso()))
|
||||
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 _strip_busy(event: dict) -> dict:
|
||||
"""Anonymise a private event for the 'busy' visibility mode."""
|
||||
event = dict(event)
|
||||
event["title"] = "Beschäftigt"
|
||||
event["location"] = ""
|
||||
event["description"] = ""
|
||||
event["private"] = True
|
||||
return event
|
||||
|
||||
|
||||
@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.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)
|
||||
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 = _strip_busy(b)
|
||||
all_events.append(b)
|
||||
|
||||
# Each member's own local calendars (excluding the group calendar to avoid dupes).
|
||||
for m in members:
|
||||
member_cals = (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(models.LocalCalendar.user_id == m.user_id)
|
||||
.all()
|
||||
)
|
||||
for cal in member_cals:
|
||||
if group_cal_id is not None and cal.id == group_cal_id:
|
||||
continue
|
||||
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}
|
||||
@@ -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"'},
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -27,6 +27,7 @@ class SettingsUpdate(BaseModel):
|
||||
text_color: Optional[str] = None
|
||||
line_color: Optional[str] = None
|
||||
bg_color: Optional[str] = None
|
||||
private_event_visibility: Optional[str] = None
|
||||
|
||||
|
||||
def _settings_dict(s: models.UserSettings) -> dict:
|
||||
@@ -46,6 +47,7 @@ def _settings_dict(s: models.UserSettings) -> dict:
|
||||
"text_color": s.text_color,
|
||||
"line_color": s.line_color,
|
||||
"bg_color": s.bg_color,
|
||||
"private_event_visibility": s.private_event_visibility or "busy",
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +84,9 @@ def update_settings(
|
||||
settings = models.UserSettings(user_id=current_user.id)
|
||||
db.add(settings)
|
||||
|
||||
if data.private_event_visibility is not None and data.private_event_visibility not in ("hidden", "busy"):
|
||||
raise HTTPException(422, "private_event_visibility must be 'hidden' or 'busy'")
|
||||
|
||||
# For these three override colours, an explicit null is meaningful
|
||||
# ("reset to default") and must be persisted as NULL. All other fields
|
||||
# keep the previous behaviour where a null/missing value is ignored.
|
||||
|
||||
@@ -35,6 +35,25 @@ def list_users(
|
||||
return [_user_dict(u) for u in db.query(models.User).all()]
|
||||
|
||||
|
||||
@router.get("/directory")
|
||||
def user_directory(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
"""Lightweight list of all users (id + display_name) for sharing/group pickers.
|
||||
|
||||
Available to any authenticated user (unlike GET / which is admin-only).
|
||||
Excludes the requesting user.
|
||||
"""
|
||||
users = (
|
||||
db.query(models.User)
|
||||
.filter(models.User.id != current_user.id)
|
||||
.order_by(models.User.username)
|
||||
.all()
|
||||
)
|
||||
return [{"id": u.id, "display_name": u.username} for u in users]
|
||||
|
||||
|
||||
@router.post("/")
|
||||
def create_user(
|
||||
req: CreateUserRequest,
|
||||
|
||||
Reference in New Issue
Block a user