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:
Scarriffle
2026-05-31 16:05:18 +02:00
parent 362cc7212c
commit 32268a18b2
13 changed files with 1582 additions and 173 deletions

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint
from sqlalchemy.orm import relationship
from database import Base
@@ -34,6 +34,11 @@ class User(Base):
"HomeAssistantAccount", back_populates="user", cascade="all, delete-orphan"
)
@property
def display_name(self) -> str:
"""No dedicated display-name column exists — fall back to the username."""
return self.username
class CalDAVAccount(Base):
__tablename__ = "caldav_accounts"
@@ -87,6 +92,9 @@ class UserSettings(Base):
text_color = Column(String(7), nullable=True) # Override für --text-1 (NULL = nutze text_contrast)
line_color = Column(String(7), nullable=True) # Override für --border (NULL = nutze line_contrast)
bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default)
# How this user's private events appear to other group members:
# 'hidden' = invisible, 'busy' = anonymous busy block (default).
private_event_visibility = Column(String(10), default="busy")
user = relationship("User", back_populates="settings")
@@ -119,8 +127,15 @@ class LocalEvent(Base):
color = Column(String(7), nullable=True)
rrule = Column(Text, nullable=True)
exdate = Column(Text, nullable=True) # Comma-separated YYYYMMDD dates to exclude
# Creator: set server-side from the auth token on create, never from the client.
creator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# For imported events without a local user (from the .ics ORGANIZER field).
creator_name_external = Column(Text, nullable=True)
# Private events are filtered for other group members per their visibility setting.
is_private = Column(Boolean, default=False)
calendar = relationship("LocalCalendar", back_populates="events")
creator = relationship("User")
class ICalSubscription(Base):
@@ -219,3 +234,74 @@ class HomeAssistantCalendar(Base):
sidebar_hidden = Column(Boolean, default=False)
account = relationship("HomeAssistantAccount", back_populates="calendars")
# ── Collaboration: sharing & groups (local calendars only) ────────────────
class CalendarShare(Base):
"""A local calendar shared with another Calendarr user."""
__tablename__ = "calendar_shares"
__table_args__ = (
UniqueConstraint("calendar_id", "user_id", name="uq_calendar_share"),
)
id = Column(Integer, primary_key=True, index=True)
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
permission = Column(String(20), default="read") # 'read' | 'read_write'
created_at = Column(String(50), nullable=True) # ISO 8601
calendar = relationship("LocalCalendar")
user = relationship("User")
class Group(Base):
__tablename__ = "groups"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(String(50), nullable=True) # ISO 8601
members = relationship(
"GroupMember", back_populates="group", cascade="all, delete-orphan"
)
group_calendar = relationship(
"GroupCalendar", back_populates="group", uselist=False,
cascade="all, delete-orphan",
)
class GroupMember(Base):
__tablename__ = "group_members"
__table_args__ = (
UniqueConstraint("group_id", "user_id", name="uq_group_member"),
)
id = Column(Integer, primary_key=True, index=True)
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'
joined_at = Column(String(50), nullable=True) # ISO 8601
group = relationship("Group", back_populates="members")
user = relationship("User")
class GroupCalendar(Base):
"""1:1 link between a group and its shared local calendar."""
__tablename__ = "group_calendars"
__table_args__ = (
UniqueConstraint("group_id", name="uq_group_calendar_group"),
UniqueConstraint("calendar_id", name="uq_group_calendar_calendar"),
)
id = Column(Integer, primary_key=True, index=True)
group_id = Column(Integer, ForeignKey("groups.id"), nullable=False)
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
group = relationship("Group", back_populates="group_calendar")
calendar = relationship("LocalCalendar")