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:
Stefan Hacker 2026-04-12 12:41:35 +02:00
parent ddd8f57e69
commit c1b05e2525
3 changed files with 165 additions and 7 deletions

View File

@ -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)

View File

@ -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,
}

View File

@ -186,6 +186,16 @@
</template>
</Dialog>
<!-- Scope picker for recurring events -->
<Dialog v-model:visible="showScopeDialog" header="Serientermin bearbeiten" modal :style="{ width: '440px' }">
<p>Dieser Termin gehoert zu einer Serie.<br>Was moechtest du bearbeiten?</p>
<template #footer>
<Button label="Abbrechen" text @click="showScopeDialog = false" />
<Button label="Nur diesen Termin" outlined @click="openEditScope('occurrence')" />
<Button label="Ganze Serie" @click="openEditScope('series')" />
</template>
</Dialog>
<Dialog v-model:visible="confirmDeleteCal" header="Kalender loeschen" modal :style="{ width: '400px' }">
<p>Moechtest du den Kalender <strong>{{ selectedCal?.name }}</strong> mit allen Terminen loeschen?</p>
<template #footer>
@ -271,6 +281,11 @@ const sharePermission = ref('read')
const icalPassword = ref('')
const confirmDeleteEvent = ref(false)
const confirmDeleteCal = ref(false)
const showScopeDialog = ref(false)
const pendingScopeEvent = ref(null)
const pendingScopeOccurrence = ref(null)
// 'series' (edit master), 'occurrence' (split off single date), or null (non-recurring)
const currentEditScope = ref(null)
const permOptions = [{ label: 'Lesen', value: 'read' }, { label: 'Lesen+Schreiben', value: 'readwrite' }]
const ownCalendars = computed(() => calendars.value.filter(c => c.permission === 'owner'))
@ -354,12 +369,14 @@ function toFcEvent(e, cal) {
extendedProps: { ...e, _cal: cal },
}
if (e.recurrence_rule) {
// FullCalendar rrule plugin consumes the RRULE together with dtstart
fc.rrule = {
freq: extractFreq(e.recurrence_rule),
dtstart: e.dtstart,
...parseRRule(e.recurrence_rule),
}
if (e.exdates && e.exdates.length) {
fc.exdate = e.exdates
}
if (e.dtend && e.dtstart) {
const ms = new Date(e.dtend).getTime() - new Date(e.dtstart).getTime()
if (ms > 0) fc.duration = msToDuration(ms)
@ -443,9 +460,47 @@ function loadRRuleIntoForm(rrule) {
function onEventClick(info) {
const e = info.event.extendedProps
if (e.recurrence_rule) {
pendingScopeEvent.value = e
pendingScopeOccurrence.value = info.event.start
showScopeDialog.value = true
return
}
currentEditScope.value = null
openEditEvent(e)
}
function openEditScope(scope) {
showScopeDialog.value = false
const e = pendingScopeEvent.value
const occ = pendingScopeOccurrence.value
if (scope === 'series') {
currentEditScope.value = 'series'
openEditEvent(e)
} else {
// "Nur diesen Termin" - form starts from the clicked occurrence's
// start/end, non-recurring. Save will add an EXDATE on the master
// plus create a standalone event.
currentEditScope.value = 'occurrence'
editingEvent.value = e
const durationMs = e.dtend ? new Date(e.dtend) - new Date(e.dtstart) : 3600000
const occEnd = new Date(occ.getTime() + durationMs)
eventForm.value = {
summary: e.summary,
description: e.description || '',
location: e.location || '',
calendar_id: e.calendar_id,
dtstart: toLocalISO(occ, e.all_day),
dtend: toLocalISO(occEnd, e.all_day),
all_day: e.all_day,
}
loadRRuleIntoForm('')
// Freeze recurrence editor - this is a single instance
recurFreq.value = 'none'
showEventDialog.value = true
}
}
function onDateSelect(info) {
openNewEvent(info.start, info.end, info.allDay)
}
@ -534,12 +589,28 @@ async function saveEvent() {
calendar_id: eventForm.value.calendar_id,
}
try {
if (editingEvent.value) {
if (currentEditScope.value === 'occurrence' && editingEvent.value) {
// Split single occurrence off the series: add EXDATE + create replacement
await apiClient.post(`/events/${editingEvent.value.id}/exception`, {
occurrence_date: toLocalISO(pendingScopeOccurrence.value, editingEvent.value.all_day),
replacement: {
summary: payload.summary,
description: payload.description,
location: payload.location,
dtstart: payload.dtstart,
dtend: payload.dtend,
all_day: payload.all_day,
},
})
} else if (editingEvent.value) {
await apiClient.put(`/events/${editingEvent.value.id}`, payload)
} else {
await apiClient.post(`/calendars/${payload.calendar_id}/events`, payload)
}
showEventDialog.value = false
currentEditScope.value = null
pendingScopeEvent.value = null
pendingScopeOccurrence.value = null
refreshEvents()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })