feat: implement comprehensive recurring event single occurrence editing

- Add detach functionality for individual occurrences of recurring events
- Create POST /api/events/<id>/occurrences/<date>/detach endpoint
- Implement EventException-based EXDATE generation for master events
- Add user confirmation dialog for single vs series editing choice
- Implement manual recurrence expansion with DST timezone tolerance
- Support FREQ=DAILY and FREQ=WEEKLY with BYDAY patterns and UNTIL dates
- Create standalone events from detached occurrences without affecting master series
- Add GET /api/events/<id> endpoint for fetching master event data
- Allow editing recurring series even when master event date is in the past
- Replace browser confirm dialogs with Syncfusion dialog components
- Remove debug logging while preserving error handling
- Update documentation for recurring event functionality

BREAKING: Frontend now manually expands recurring events instead of relying on Syncfusion's EXDATE handling

This enables users to edit individual occurrences of recurring events (creating standalone events)
or edit the entire series (updating all future occurrences) through an intuitive UI workflow.
The system properly handles timezone transitions, holiday exclusions, and complex recurrence patterns.
This commit is contained in:
RobbStarkAustria
2025-10-12 20:04:23 +00:00
parent 773628c324
commit e53cc619ec
6 changed files with 739 additions and 120 deletions

View File

@@ -54,32 +54,37 @@ def get_events():
if not (show_inactive or e.is_active):
continue
# Gather exception dates for this event (for recurrenceException/EXDATE)
exception_dates = session.query(EventException).filter(
EventException.event_id == e.id,
EventException.is_skipped == True
# Gather exceptions for this event
all_exceptions = session.query(EventException).filter(
EventException.event_id == e.id
).all()
# Syncfusion expects recurrenceException as comma-separated ISO strings (yyyy-MM-ddTHH:mm:ssZ)
# IMPORTANT: The time must match the event's occurrence start time. Use the event's start time-of-day.
# Build RecurrenceException (EXDATE) tokens for skipped occurrences only
# (detached occurrences are now real Event rows, not synthetic)
recurrence_exception = None
if exception_dates:
# Use event start time in UTC as baseline
if all_exceptions:
base_start = e.start.astimezone(UTC) if e.start.tzinfo else e.start.replace(tzinfo=UTC)
tokens = []
for d in exception_dates:
exd = d.exception_date # date
occ_dt = datetime(
exd.year, exd.month, exd.day,
base_start.hour, base_start.minute, base_start.second,
tzinfo=UTC
)
tokens.append(occ_dt.strftime('%Y-%m-%dT%H:%M:%SZ'))
recurrence_exception = ','.join(tokens)
for ex in all_exceptions:
if ex.is_skipped:
exd = ex.exception_date
# Create the EXDATE timestamp using the master event's original start time
# This should match the time when the original occurrence would have happened
occ_dt = datetime(
exd.year, exd.month, exd.day,
base_start.hour, base_start.minute, base_start.second,
tzinfo=UTC
)
token = occ_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
tokens.append(token)
if tokens:
recurrence_exception = ','.join(tokens)
base_payload = {
"Id": str(e.id),
"GroupId": e.group_id,
"Subject": e.title,
"Description": getattr(e, 'description', None),
"StartTime": e.start.isoformat() if e.start else None,
"EndTime": e.end.isoformat() if e.end else None,
"IsAllDay": False,
@@ -93,23 +98,228 @@ def get_events():
"SkipHolidays": bool(getattr(e, 'skip_holidays', False)),
}
result.append(base_payload)
# No need to emit synthetic override events anymore since detached occurrences
# are now real Event rows that will be returned in the main query
session.close()
return jsonify(result)
@events_bp.route("/<event_id>", methods=["DELETE"])
@events_bp.route("/<event_id>", methods=["GET"]) # get single event
def get_event(event_id):
session = Session()
try:
event = session.query(Event).filter_by(id=event_id).first()
if not event:
return jsonify({"error": "Termin nicht gefunden"}), 404
# Convert event to dictionary with all necessary fields
event_dict = {
"Id": str(event.id),
"Subject": event.title,
"StartTime": event.start.isoformat() if event.start else None,
"EndTime": event.end.isoformat() if event.end else None,
"Description": event.description,
"Type": event.event_type.value if event.event_type else "presentation",
"IsAllDay": False, # Assuming events are not all-day by default
"MediaId": str(event.event_media_id) if event.event_media_id else None,
"SlideshowInterval": event.slideshow_interval,
"WebsiteUrl": event.event_media.url if event.event_media and hasattr(event.event_media, 'url') else None,
"RecurrenceRule": event.recurrence_rule,
"RecurrenceEnd": event.recurrence_end.isoformat() if event.recurrence_end else None,
"SkipHolidays": event.skip_holidays,
"Icon": get_icon_for_type(event.event_type.value if event.event_type else "presentation"),
}
return jsonify(event_dict)
except Exception as e:
return jsonify({"error": f"Fehler beim Laden des Termins: {str(e)}"}), 500
finally:
session.close()
@events_bp.route("/<event_id>", methods=["DELETE"]) # delete series or single event
def delete_event(event_id):
session = Session()
event = session.query(Event).filter_by(id=event_id).first()
if not event:
session.close()
return jsonify({"error": "Termin nicht gefunden"}), 404
# Safety: do not allow accidental deletion of a recurring master without explicit force flag
force = request.args.get('force') == '1'
if event.recurrence_rule and not force:
session.close()
return jsonify({
"error": "Löschen der Terminserie erfordert Bestätigung",
"hint": "Fügen Sie ?force=1 zur Anfrage hinzu, um die Serie zu löschen.",
"event_id": event_id
}), 400
session.delete(event)
session.commit()
session.close()
return jsonify({"success": True})
@events_bp.route("/<event_id>/occurrences/<occurrence_date>", methods=["DELETE"]) # skip single occurrence
def delete_event_occurrence(event_id, occurrence_date):
"""Delete a single occurrence of a recurring event by creating an EventException."""
session = Session()
try:
# Validate event exists
event = session.query(Event).filter_by(id=event_id).first()
if not event:
session.close()
return jsonify({"error": "Termin nicht gefunden"}), 404
# Validate that this is a recurring event
if not event.recurrence_rule:
session.close()
return jsonify({"error": "Termin ist keine Wiederholungsserie"}), 400
# Parse the occurrence date
try:
occ_date = datetime.fromisoformat(occurrence_date).date()
except ValueError:
session.close()
return jsonify({"error": "Ungültiges Datumsformat"}), 400
# Check if an exception for this date already exists
existing_exception = session.query(EventException).filter_by(
event_id=event.id,
exception_date=occ_date
).first()
if existing_exception:
# Update existing exception to be skipped
existing_exception.is_skipped = True
existing_exception.updated_at = datetime.now()
else:
# Create new exception to skip this occurrence
exception = EventException(
event_id=event.id,
exception_date=occ_date,
is_skipped=True
)
session.add(exception)
session.commit()
session.close()
return jsonify({"success": True, "message": "Einzeltermin wurde gelöscht"})
except Exception as e:
session.rollback()
session.close()
return jsonify({"error": f"Fehler beim Löschen des Einzeltermins: {str(e)}"}), 500
@events_bp.route("/<event_id>/occurrences/<occurrence_date>/detach", methods=["POST"]) # detach single occurrence into standalone event
def detach_event_occurrence(event_id, occurrence_date):
"""BULLETPROOF: Detach single occurrence without touching master event."""
session = Session()
try:
data = request.json or {}
# Step 0: Get master event and NEVER modify it
master = session.query(Event).filter_by(id=event_id).first()
if not master:
session.close()
return jsonify({"error": "Termin nicht gefunden"}), 404
if not master.recurrence_rule:
session.close()
return jsonify({"error": "Termin ist keine Wiederholungsserie"}), 400
# Store master data (read-only copy)
master_data = {
'id': master.id,
'group_id': master.group_id,
'title': master.title,
'description': master.description,
'start': master.start,
'end': master.end,
'event_type': master.event_type,
'event_media_id': master.event_media_id,
'slideshow_interval': getattr(master, 'slideshow_interval', None),
'created_by': master.created_by,
}
try:
occ_date = datetime.fromisoformat(occurrence_date).date()
except ValueError:
session.close()
return jsonify({"error": "Ungültiges Datumsformat"}), 400
# Step 1: Create exception entry (using master ID, not master object)
existing_exception = session.query(EventException).filter_by(
event_id=master_data['id'],
exception_date=occ_date
).first()
if not existing_exception:
exception = EventException(
event_id=master_data['id'],
exception_date=occ_date,
is_skipped=True
)
session.add(exception)
else:
existing_exception.is_skipped = True
# Step 2: Create new standalone event (using copied data, not master object)
new_title = data.get("title", master_data['title'])
new_description = data.get("description", master_data['description'])
if data.get("start"):
new_start = datetime.fromisoformat(data["start"])
else:
base_start_utc = master_data['start'].astimezone(UTC) if master_data['start'].tzinfo else master_data['start'].replace(tzinfo=UTC)
new_start = datetime(occ_date.year, occ_date.month, occ_date.day,
base_start_utc.hour, base_start_utc.minute, base_start_utc.second, tzinfo=UTC)
if data.get("end"):
new_end = datetime.fromisoformat(data["end"])
else:
duration = (master_data['end'] - master_data['start']) if (master_data['end'] and master_data['start']) else timedelta(minutes=30)
new_end = new_start + duration
new_event = Event(
group_id=master_data['group_id'],
title=new_title,
description=new_description,
start=new_start,
end=new_end,
event_type=master_data['event_type'],
event_media_id=master_data['event_media_id'],
slideshow_interval=master_data['slideshow_interval'],
recurrence_rule=None,
recurrence_end=None,
skip_holidays=False,
created_by=master_data['created_by'],
updated_by=master_data['created_by'],
is_active=True,
)
session.add(new_event)
# Commit both changes at once
session.commit()
new_event_id = new_event.id
session.close()
return jsonify({
"success": True,
"new_event_id": new_event_id,
"master_event_id": master_data['id'],
"message": f"Einzeltermin erstellt, Master-Event {master_data['id']} unberührt"
})
except Exception as e:
session.rollback()
session.close()
return jsonify({"error": f"Fehler beim Erstellen des Einzeltermins: {str(e)}"}), 500
@events_bp.route("", methods=["POST"])
def create_event():
data = request.json
@@ -187,8 +397,15 @@ def create_event():
from models.models import SchoolHoliday, EventException
from dateutil.rrule import rrulestr
from dateutil.tz import UTC
# Clear existing exceptions for this event
session.query(EventException).filter_by(event_id=ev.id).delete()
# Clear only auto-generated holiday skip exceptions, keep user overrides
session.query(EventException).filter(
EventException.event_id == ev.id,
EventException.is_skipped == True,
EventException.override_title.is_(None),
EventException.override_description.is_(None),
EventException.override_start.is_(None),
EventException.override_end.is_(None),
).delete(synchronize_session=False)
session.commit()
if not (ev.skip_holidays and ev.recurrence_rule):
return
@@ -219,7 +436,7 @@ def create_event():
return jsonify({"success": True, "event_id": event.id})
@events_bp.route("/<event_id>", methods=["PUT"])
@events_bp.route("/<event_id>", methods=["PUT"]) # update series or single event
def update_event(event_id):
data = request.json
session = Session()
@@ -262,21 +479,30 @@ def update_event(event_id):
prev_skip != bool(getattr(event, 'skip_holidays', False))
)
if need_regen:
# Re-use helper from create route
# Re-use helper from create route (preserve user overrides)
def regenerate_event_exceptions(ev: Event):
from models.models import SchoolHoliday, EventException
from dateutil.rrule import rrulestr
from dateutil.tz import UTC
# Clear existing exceptions
session.query(EventException).filter_by(event_id=ev.id).delete()
# Clear only auto-generated holiday skip exceptions, keep user overrides
session.query(EventException).filter(
EventException.event_id == ev.id,
EventException.is_skipped == True,
EventException.override_title.is_(None),
EventException.override_description.is_(None),
EventException.override_start.is_(None),
EventException.override_end.is_(None),
).delete(synchronize_session=False)
session.commit()
if not (ev.skip_holidays and ev.recurrence_rule):
return
# Get holidays
holidays = session.query(SchoolHoliday).all()
dtstart = ev.start.astimezone(UTC)
r = rrulestr(ev.recurrence_rule, dtstart=dtstart)
window_start = dtstart
window_end = ev.recurrence_end.astimezone(UTC) if ev.recurrence_end else dtstart.replace(year=dtstart.year + 1)
# Build set of all holiday dates (inclusive)
holiday_dates = set()
for h in holidays:
hs = h.start_date
@@ -285,6 +511,7 @@ def update_event(event_id):
while d <= he:
holiday_dates.add(d)
d = d + timedelta(days=1)
# Create exceptions for occurrences on holiday dates
for occ_start in r.between(window_start, window_end, inc=True):
occ_date = occ_start.date()
if occ_date in holiday_dates:
@@ -293,6 +520,7 @@ def update_event(event_id):
regenerate_event_exceptions(event)
event_id_return = event.id # <-- ID vor session.close() speichern!
# Return success with event id
event_id_return = event.id
session.close()
return jsonify({"success": True, "event_id": event_id_return})