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:
@@ -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')
|
||||
|
||||
@@ -21,6 +21,7 @@ class Calendar(db.Model):
|
||||
cascade='all, delete-orphan')
|
||||
shares = db.relationship('CalendarShare', backref='calendar', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
owner = db.relationship('User', foreign_keys=[owner_id])
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
@@ -50,6 +51,7 @@ class CalendarEvent(db.Model):
|
||||
all_day = db.Column(db.Boolean, default=False)
|
||||
recurrence_rule = db.Column(db.Text, nullable=True)
|
||||
exdates = db.Column(db.Text, nullable=True) # Komma-separiert, ISO-Datum (YYYY-MM-DD)
|
||||
is_private = db.Column(db.Boolean, default=False, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
@@ -67,6 +69,7 @@ class CalendarEvent(db.Model):
|
||||
'all_day': self.all_day,
|
||||
'recurrence_rule': self.recurrence_rule,
|
||||
'exdates': self.exdates.split(',') if self.exdates else [],
|
||||
'is_private': bool(self.is_private),
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user