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:
126
backend/permissions.py
Normal file
126
backend/permissions.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Central access control for local calendars.
|
||||
|
||||
Local calendars are visible/writable to a user if any of the following holds:
|
||||
- the user owns the calendar (LocalCalendar.user_id),
|
||||
- the calendar is shared with the user (calendar_shares; write needs 'read_write'),
|
||||
- the calendar is a group calendar and the user is a member of that group
|
||||
(members get read & write).
|
||||
|
||||
These helpers replace the scattered owner-only filters so sharing and groups
|
||||
work consistently across every local-calendar endpoint and the event merge read.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import models
|
||||
|
||||
|
||||
def _share_for(db: Session, calendar_id: int, user_id: int) -> Optional[models.CalendarShare]:
|
||||
return (
|
||||
db.query(models.CalendarShare)
|
||||
.filter(
|
||||
models.CalendarShare.calendar_id == calendar_id,
|
||||
models.CalendarShare.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _is_group_calendar_member(db: Session, calendar_id: int, user_id: int) -> bool:
|
||||
gc = (
|
||||
db.query(models.GroupCalendar)
|
||||
.filter(models.GroupCalendar.calendar_id == calendar_id)
|
||||
.first()
|
||||
)
|
||||
if not gc:
|
||||
return False
|
||||
member = (
|
||||
db.query(models.GroupMember)
|
||||
.filter(
|
||||
models.GroupMember.group_id == gc.group_id,
|
||||
models.GroupMember.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return member is not None
|
||||
|
||||
|
||||
def accessible_local_calendar(
|
||||
db: Session,
|
||||
user: models.User,
|
||||
calendar_id: int,
|
||||
*,
|
||||
require_write: bool = False,
|
||||
) -> models.LocalCalendar:
|
||||
"""Return the calendar if the user may access it, else raise 404/403.
|
||||
|
||||
404 when the calendar does not exist or is not visible to the user (so we
|
||||
don't leak existence). 403 when it is visible (read) but write is required.
|
||||
"""
|
||||
cal = (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(models.LocalCalendar.id == calendar_id)
|
||||
.first()
|
||||
)
|
||||
if not cal:
|
||||
raise HTTPException(404, "Calendar not found")
|
||||
|
||||
if cal.user_id == user.id:
|
||||
return cal # owner: full access
|
||||
|
||||
if _is_group_calendar_member(db, calendar_id, user.id):
|
||||
return cal # group members get read & write
|
||||
|
||||
share = _share_for(db, calendar_id, user.id)
|
||||
if share is None:
|
||||
raise HTTPException(404, "Calendar not found")
|
||||
if require_write and share.permission != "read_write":
|
||||
raise HTTPException(403, "You only have read access to this calendar")
|
||||
return cal
|
||||
|
||||
|
||||
def is_calendar_owner(db: Session, user: models.User, calendar_id: int) -> models.LocalCalendar:
|
||||
"""Return the calendar only if the user owns it, else raise 404."""
|
||||
cal = (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalCalendar.id == calendar_id,
|
||||
models.LocalCalendar.user_id == user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not cal:
|
||||
raise HTTPException(404, "Calendar not found")
|
||||
return cal
|
||||
|
||||
|
||||
def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]:
|
||||
"""All local calendar ids the user may read: own + shared + group calendars."""
|
||||
ids: set[int] = set()
|
||||
|
||||
own = (
|
||||
db.query(models.LocalCalendar.id)
|
||||
.filter(models.LocalCalendar.user_id == user.id)
|
||||
.all()
|
||||
)
|
||||
ids.update(r[0] for r in own)
|
||||
|
||||
shared = (
|
||||
db.query(models.CalendarShare.calendar_id)
|
||||
.filter(models.CalendarShare.user_id == user.id)
|
||||
.all()
|
||||
)
|
||||
ids.update(r[0] for r in shared)
|
||||
|
||||
group_cals = (
|
||||
db.query(models.GroupCalendar.calendar_id)
|
||||
.join(models.GroupMember, models.GroupMember.group_id == models.GroupCalendar.group_id)
|
||||
.filter(models.GroupMember.user_id == user.id)
|
||||
.all()
|
||||
)
|
||||
ids.update(r[0] for r in group_cals)
|
||||
|
||||
return list(ids)
|
||||
Reference in New Issue
Block a user