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()
|
rrule = (data.get('recurrence_rule') or '').strip()
|
||||||
|
|
||||||
ical_data = _build_ical(event_uid, summary, dtstart_dt, dtend_dt, all_day,
|
ical_data = _build_ical(event_uid, summary, dtstart_dt, dtend_dt, all_day,
|
||||||
description, location, rrule)
|
description, location, rrule, None)
|
||||||
|
|
||||||
event = CalendarEvent(
|
event = CalendarEvent(
|
||||||
calendar_id=cal_id,
|
calendar_id=cal_id,
|
||||||
|
|
@ -226,13 +226,87 @@ def update_event(event_id):
|
||||||
event.ical_data = _build_ical(
|
event.ical_data = _build_ical(
|
||||||
event.uid, event.summary, event.dtstart, event.dtend,
|
event.uid, event.summary, event.dtstart, event.dtend,
|
||||||
event.all_day, event.description or '', event.location or '',
|
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)
|
event.updated_at = datetime.now(timezone.utc)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify(event.to_dict()), 200
|
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'])
|
@api_bp.route('/events/<int:event_id>', methods=['DELETE'])
|
||||||
@token_required
|
@token_required
|
||||||
def delete_event(event_id):
|
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')
|
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:
|
if not dtend:
|
||||||
dtend = dtstart
|
dtend = dtstart
|
||||||
lines = [
|
lines = [
|
||||||
|
|
@ -443,10 +517,21 @@ def _build_vevent(uid, summary, dtstart, dtend, all_day, description='', locatio
|
||||||
lines.append(f'LOCATION:{location}')
|
lines.append(f'LOCATION:{location}')
|
||||||
if rrule:
|
if rrule:
|
||||||
lines.append(f'RRULE:{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(f'DTSTAMP:{datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")}')
|
||||||
lines.append('END:VEVENT')
|
lines.append('END:VEVENT')
|
||||||
return '\r\n'.join(lines)
|
return '\r\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _build_ical(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)
|
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)
|
dtend = db.Column(db.DateTime, nullable=True)
|
||||||
all_day = db.Column(db.Boolean, default=False)
|
all_day = db.Column(db.Boolean, default=False)
|
||||||
recurrence_rule = db.Column(db.Text, nullable=True)
|
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))
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||||
updated_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))
|
onupdate=lambda: datetime.now(timezone.utc))
|
||||||
|
|
@ -65,6 +66,7 @@ class CalendarEvent(db.Model):
|
||||||
'dtend': self.dtend.isoformat() if self.dtend else None,
|
'dtend': self.dtend.isoformat() if self.dtend else None,
|
||||||
'all_day': self.all_day,
|
'all_day': self.all_day,
|
||||||
'recurrence_rule': self.recurrence_rule,
|
'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,
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,16 @@
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</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' }">
|
<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>
|
<p>Moechtest du den Kalender <strong>{{ selectedCal?.name }}</strong> mit allen Terminen loeschen?</p>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|
@ -271,6 +281,11 @@ const sharePermission = ref('read')
|
||||||
const icalPassword = ref('')
|
const icalPassword = ref('')
|
||||||
const confirmDeleteEvent = ref(false)
|
const confirmDeleteEvent = ref(false)
|
||||||
const confirmDeleteCal = 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 permOptions = [{ label: 'Lesen', value: 'read' }, { label: 'Lesen+Schreiben', value: 'readwrite' }]
|
||||||
|
|
||||||
const ownCalendars = computed(() => calendars.value.filter(c => c.permission === 'owner'))
|
const ownCalendars = computed(() => calendars.value.filter(c => c.permission === 'owner'))
|
||||||
|
|
@ -354,12 +369,14 @@ function toFcEvent(e, cal) {
|
||||||
extendedProps: { ...e, _cal: cal },
|
extendedProps: { ...e, _cal: cal },
|
||||||
}
|
}
|
||||||
if (e.recurrence_rule) {
|
if (e.recurrence_rule) {
|
||||||
// FullCalendar rrule plugin consumes the RRULE together with dtstart
|
|
||||||
fc.rrule = {
|
fc.rrule = {
|
||||||
freq: extractFreq(e.recurrence_rule),
|
freq: extractFreq(e.recurrence_rule),
|
||||||
dtstart: e.dtstart,
|
dtstart: e.dtstart,
|
||||||
...parseRRule(e.recurrence_rule),
|
...parseRRule(e.recurrence_rule),
|
||||||
}
|
}
|
||||||
|
if (e.exdates && e.exdates.length) {
|
||||||
|
fc.exdate = e.exdates
|
||||||
|
}
|
||||||
if (e.dtend && e.dtstart) {
|
if (e.dtend && e.dtstart) {
|
||||||
const ms = new Date(e.dtend).getTime() - new Date(e.dtstart).getTime()
|
const ms = new Date(e.dtend).getTime() - new Date(e.dtstart).getTime()
|
||||||
if (ms > 0) fc.duration = msToDuration(ms)
|
if (ms > 0) fc.duration = msToDuration(ms)
|
||||||
|
|
@ -443,9 +460,47 @@ function loadRRuleIntoForm(rrule) {
|
||||||
|
|
||||||
function onEventClick(info) {
|
function onEventClick(info) {
|
||||||
const e = info.event.extendedProps
|
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)
|
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) {
|
function onDateSelect(info) {
|
||||||
openNewEvent(info.start, info.end, info.allDay)
|
openNewEvent(info.start, info.end, info.allDay)
|
||||||
}
|
}
|
||||||
|
|
@ -534,12 +589,28 @@ async function saveEvent() {
|
||||||
calendar_id: eventForm.value.calendar_id,
|
calendar_id: eventForm.value.calendar_id,
|
||||||
}
|
}
|
||||||
try {
|
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)
|
await apiClient.put(`/events/${editingEvent.value.id}`, payload)
|
||||||
} else {
|
} else {
|
||||||
await apiClient.post(`/calendars/${payload.calendar_id}/events`, payload)
|
await apiClient.post(`/calendars/${payload.calendar_id}/events`, payload)
|
||||||
}
|
}
|
||||||
showEventDialog.value = false
|
showEventDialog.value = false
|
||||||
|
currentEditScope.value = null
|
||||||
|
pendingScopeEvent.value = null
|
||||||
|
pendingScopeOccurrence.value = null
|
||||||
refreshEvents()
|
refreshEvents()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue