feat: Serientermin-Bearbeitung: Nur diesen Termin oder Serie
Klick auf einen wiederkehrenden Termin oeffnet zuerst einen Dialog: "Nur diesen Termin" oder "Ganze Serie". * Serie: bearbeitet den Master wie bisher * Nur dieser: fuegt EXDATE fuer das geklickte Datum zum Master hinzu und legt einen eigenstaendigen Ersatz-Termin mit den bearbeiteten Daten an Backend: * CalendarEvent.exdates speichert Ausnahmedaten kommasepariert * POST /events/<id>/exception fuegt EXDATE hinzu, erstellt optional das Replacement-Event mit frischer UID * _build_vevent schreibt jetzt EXDATE-Zeilen in die ical_data, sodass CalDAV-Clients die Ausnahmen auch sehen werden Frontend: * FullCalendar rrule-Plugin bekommt die exdate-Liste und blendet die uebersprungenen Tage aus * Drag & Drop verschiebt weiterhin die ganze Serie (Shortcut - fuer Einzelverschiebung Termin anklicken und bearbeiten) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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/<int:event_id>/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/<int:event_id>', 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)
|
||||
|
||||
Reference in New Issue
Block a user