diff --git a/backend/app/api/calendar.py b/backend/app/api/calendar.py index a14a1e4..773e78f 100644 --- a/backend/app/api/calendar.py +++ b/backend/app/api/calendar.py @@ -171,7 +171,7 @@ def create_event(cal_id): rrule = (data.get('recurrence_rule') or '').strip() ical_data = _build_ical(event_uid, summary, dtstart_dt, dtend_dt, all_day, - description, location, rrule) + description, location, rrule, None) event = CalendarEvent( calendar_id=cal_id, @@ -226,13 +226,87 @@ def update_event(event_id): event.ical_data = _build_ical( event.uid, event.summary, event.dtstart, event.dtend, event.all_day, event.description or '', event.location or '', - event.recurrence_rule or '' + event.recurrence_rule or '', + event.exdates.split(',') if event.exdates else None, ) event.updated_at = datetime.now(timezone.utc) db.session.commit() return jsonify(event.to_dict()), 200 +@api_bp.route('/events//exception', methods=['POST']) +@token_required +def add_event_exception(event_id): + """Exclude a single occurrence of a recurring event ("nur dieser Termin"). + Optionally creates a standalone replacement event for that date.""" + user = request.current_user + event = db.session.get(CalendarEvent, event_id) + if not event: + return jsonify({'error': 'Event nicht gefunden'}), 404 + + cal, err = _get_calendar_or_err(event.calendar_id, user, need_write=True) + if err: + return err + if not event.recurrence_rule: + return jsonify({'error': 'Kein Serientermin'}), 400 + + data = request.get_json() + occurrence_date = data.get('occurrence_date') # ISO date or datetime + if not occurrence_date: + return jsonify({'error': 'occurrence_date erforderlich'}), 400 + + # Normalize to YYYY-MM-DD for storage key + try: + parsed = datetime.fromisoformat(occurrence_date.replace('Z', '+00:00')) + key = parsed.strftime('%Y-%m-%d' if event.all_day else '%Y-%m-%dT%H:%M:%S') + except ValueError: + key = occurrence_date + + existing = (event.exdates or '').split(',') if event.exdates else [] + if key not in existing: + existing.append(key) + event.exdates = ','.join(filter(None, existing)) + + # Optional: create replacement single event + replacement = None + if data.get('replacement'): + r = data['replacement'] + rep_uid = str(uuid.uuid4()) + rep_start = datetime.fromisoformat(r['dtstart']) + rep_end = datetime.fromisoformat(r['dtend']) if r.get('dtend') else rep_start + replacement = CalendarEvent( + calendar_id=event.calendar_id, + uid=rep_uid, + summary=r.get('summary', event.summary), + description=r.get('description', event.description), + location=r.get('location', event.location), + dtstart=rep_start, + dtend=rep_end, + all_day=r.get('all_day', event.all_day), + recurrence_rule=None, + ical_data='', + ) + replacement.ical_data = _build_ical( + rep_uid, replacement.summary, rep_start, rep_end, + replacement.all_day, replacement.description or '', + replacement.location or '', '', + ) + db.session.add(replacement) + + event.ical_data = _build_ical( + event.uid, event.summary, event.dtstart, event.dtend, + event.all_day, event.description or '', event.location or '', + event.recurrence_rule or '', + event.exdates.split(',') if event.exdates else None, + ) + event.updated_at = datetime.now(timezone.utc) + db.session.commit() + return jsonify({ + 'event': event.to_dict(), + 'replacement': replacement.to_dict() if replacement else None, + }), 200 + + @api_bp.route('/events/', methods=['DELETE']) @token_required def delete_event(event_id): @@ -423,7 +497,7 @@ def _format_dt(dt, all_day=False): return dt.strftime('%Y%m%dT%H%M%SZ') -def _build_vevent(uid, summary, dtstart, dtend, all_day, description='', location='', rrule=''): +def _build_vevent(uid, summary, dtstart, dtend, all_day, description='', location='', rrule='', exdates=None): if not dtend: dtend = dtstart lines = [ @@ -443,10 +517,21 @@ def _build_vevent(uid, summary, dtstart, dtend, all_day, description='', locatio lines.append(f'LOCATION:{location}') if rrule: lines.append(f'RRULE:{rrule}') + if exdates: + for ex in exdates: + if all_day: + lines.append(f'EXDATE;VALUE=DATE:{ex.replace("-", "")}') + else: + # Convert ISO datetime (with or without TZ) into YYYYMMDDTHHMMSSZ + try: + dt = datetime.fromisoformat(ex.replace('Z', '+00:00')) + lines.append(f'EXDATE:{dt.strftime("%Y%m%dT%H%M%SZ")}') + except ValueError: + pass lines.append(f'DTSTAMP:{datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")}') lines.append('END:VEVENT') return '\r\n'.join(lines) -def _build_ical(uid, summary, dtstart, dtend, all_day, description='', location='', rrule=''): - return _build_vevent(uid, summary, dtstart, dtend, all_day, description, location, rrule) +def _build_ical(uid, summary, dtstart, dtend, all_day, description='', location='', rrule='', exdates=None): + return _build_vevent(uid, summary, dtstart, dtend, all_day, description, location, rrule, exdates) diff --git a/backend/app/models/calendar.py b/backend/app/models/calendar.py index 06632aa..ddc1eb0 100644 --- a/backend/app/models/calendar.py +++ b/backend/app/models/calendar.py @@ -49,6 +49,7 @@ class CalendarEvent(db.Model): dtend = db.Column(db.DateTime, nullable=True) 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) 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)) @@ -65,6 +66,7 @@ class CalendarEvent(db.Model): 'dtend': self.dtend.isoformat() if self.dtend else None, 'all_day': self.all_day, 'recurrence_rule': self.recurrence_rule, + 'exdates': self.exdates.split(',') if self.exdates else [], 'created_at': self.created_at.isoformat() if self.created_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None, } diff --git a/frontend/src/views/CalendarView.vue b/frontend/src/views/CalendarView.vue index 959f237..59d49bd 100644 --- a/frontend/src/views/CalendarView.vue +++ b/frontend/src/views/CalendarView.vue @@ -186,6 +186,16 @@ + + +

Dieser Termin gehoert zu einer Serie.
Was moechtest du bearbeiten?

+ +
+

Moechtest du den Kalender {{ selectedCal?.name }} mit allen Terminen loeschen?