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:
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user