feat: Kalender - Autocomplete + Privat-Flag + Share-Liste + Bugfix

Sharing-Fix:
Calendar-Model hatte keine owner-Relation zu User - list_calendars
stuerzte beim Listen geteilter Kalender ab (c.owner.username ->
AttributeError). Jetzt mit explizitem foreign_keys Relationship.

Benutzer-Autocomplete:
"Kalender teilen" nutzt jetzt /users/search wie bei Dateien.
Tippt man 2+ Zeichen, erscheint ein Dropdown mit passenden
Benutzernamen. Klick uebernimmt den Namen.

Bestehende Freigaben werden im Menue angezeigt mit Muelleimer
zum Entfernen.

Privat-Flag fuer Termine:
CalendarEvent bekommt is_private-Spalte. Checkbox im Termin-
Dialog "🔒 Privat (Teilnehmer sehen nur den Zeitblock)".

Redaction greift an drei Stellen:
* GET /events: Nicht-Owner sehen summary="Privat", description
  und location = null. Zeitfenster bleibt voll sichtbar.
* iCal-Export (/ical/<token>): Privat-Events werden mit
  CLASS:PRIVATE ausgegeben und SUMMARY/DESCRIPTION/LOCATION
  werden gestrippt.
* CalDAV: aktuell werden eh nur eigene Kalender exportiert,
  also keine Redaction noetig. Kommt bei Share-Support rein.

Der Eigentuemer sieht natuerlich in seiner eigenen Ansicht alle
Details seines privaten Termins.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-12 12:56:25 +02:00
parent 5797a7b738
commit a143325bbe
3 changed files with 133 additions and 4 deletions
+55 -2
View File
@@ -11,6 +11,52 @@ from app.models.calendar import Calendar, CalendarEvent, CalendarShare
from app.models.user import User
def _redact_if_private(event_dict: dict, is_owner: bool) -> dict:
"""For shared viewers, strip summary/description/location from private
events so only the time slot remains visible."""
if is_owner or not event_dict.get('is_private'):
return event_dict
d = dict(event_dict)
d['summary'] = 'Privat'
d['description'] = None
d['location'] = None
return d
def _redact_vevent(raw: str) -> str:
"""Strip SUMMARY/DESCRIPTION/LOCATION from a VEVENT block and set
CLASS:PRIVATE. Used for shared iCal exports and CalDAV responses."""
if not raw:
return raw
import re as _re
out_lines = []
has_class = False
for line in raw.split('\n'):
stripped = line.rstrip('\r')
upper = stripped.split(':', 1)[0].split(';', 1)[0].upper()
if upper == 'SUMMARY':
out_lines.append('SUMMARY:Privat')
elif upper in ('DESCRIPTION', 'LOCATION'):
continue
elif upper == 'CLASS':
has_class = True
out_lines.append('CLASS:PRIVATE')
else:
out_lines.append(stripped)
if not has_class:
# Inject CLASS right after UID if possible, else before END:VEVENT
for i, l in enumerate(out_lines):
if l.startswith('UID:'):
out_lines.insert(i + 1, 'CLASS:PRIVATE')
break
else:
for i, l in enumerate(out_lines):
if l.upper().startswith('END:VEVENT'):
out_lines.insert(i, 'CLASS:PRIVATE')
break
return '\r\n'.join(out_lines)
def _get_calendar_or_err(cal_id, user, need_write=False):
cal = db.session.get(Calendar, cal_id)
if not cal:
@@ -136,7 +182,8 @@ def list_events(cal_id):
pass
events = query.order_by(CalendarEvent.dtstart).all()
return jsonify([e.to_dict() for e in events]), 200
is_owner = (cal.owner_id == user.id)
return jsonify([_redact_if_private(e.to_dict(), is_owner) for e in events]), 200
@api_bp.route('/calendars/<int:cal_id>/events', methods=['POST'])
@@ -184,6 +231,7 @@ def create_event(cal_id):
dtend=dtend_dt,
all_day=all_day,
recurrence_rule=rrule or None,
is_private=bool(data.get('is_private', False)),
)
db.session.add(event)
db.session.commit()
@@ -217,6 +265,8 @@ def update_event(event_id):
event.all_day = data['all_day']
if 'recurrence_rule' in data:
event.recurrence_rule = (data['recurrence_rule'] or '').strip() or None
if 'is_private' in data:
event.is_private = bool(data['is_private'])
if 'calendar_id' in data:
new_cal, cerr = _get_calendar_or_err(data['calendar_id'], user, need_write=True)
if cerr:
@@ -477,7 +527,10 @@ def ical_export(token):
]
for e in events:
if e.ical_data:
lines.append(e.ical_data)
block = _redact_vevent(e.ical_data) if e.is_private else e.ical_data
lines.append(block)
elif e.is_private:
lines.append(_build_vevent(e.uid, 'Privat', e.dtstart, e.dtend, e.all_day))
else:
lines.append(_build_vevent(e.uid, e.summary, e.dtstart, e.dtend, e.all_day))
lines.append('END:VCALENDAR')