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:
parent
ddd8f57e69
commit
c1b05e2525
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
Loading…
Reference in New Issue