Backend: generate EventException on create/update when skip_holidays or recurrence changes; emit RecurrenceException (EXDATE) with exact occurrence start time (UTC) API: return master events with RecurrenceRule + RecurrenceException Frontend: map RecurrenceException → recurrenceException; ensure SkipHolidays instances never render on holidays; place TentTree icon (black) next to main event icon via template Docs: update README and Copilot instructions for recurrence/holiday behavior Cleanup: remove dataSource and debug console logs
299 lines
11 KiB
Python
299 lines
11 KiB
Python
from flask import Blueprint, request, jsonify
|
|
from server.database import Session
|
|
from models.models import Event, EventMedia, MediaType, EventException
|
|
from datetime import datetime, timezone, timedelta
|
|
from sqlalchemy import and_
|
|
from dateutil.rrule import rrulestr
|
|
from dateutil.tz import UTC
|
|
import sys
|
|
sys.path.append('/workspace')
|
|
|
|
events_bp = Blueprint("events", __name__, url_prefix="/api/events")
|
|
|
|
|
|
def get_icon_for_type(event_type):
|
|
# Lucide-Icon-Namen als String
|
|
return {
|
|
"presentation": "Presentation", # <--- geändert!
|
|
"website": "Globe",
|
|
"video": "Video",
|
|
"message": "MessageSquare",
|
|
"webuntis": "School",
|
|
}.get(event_type, "")
|
|
|
|
|
|
@events_bp.route("", methods=["GET"])
|
|
def get_events():
|
|
session = Session()
|
|
start = request.args.get("start")
|
|
end = request.args.get("end")
|
|
group_id = request.args.get("group_id")
|
|
show_inactive = request.args.get(
|
|
"show_inactive", "0") == "1" # Checkbox-Logik
|
|
# Always let Syncfusion handle recurrence; do not expand on backend
|
|
expand = False
|
|
|
|
now = datetime.now(timezone.utc)
|
|
events_query = session.query(Event)
|
|
if group_id:
|
|
events_query = events_query.filter(Event.group_id == int(group_id))
|
|
events = events_query.all()
|
|
|
|
result = []
|
|
for e in events:
|
|
# Zeitzonen-Korrektur für e.end
|
|
if e.end and e.end.tzinfo is None:
|
|
end_dt = e.end.replace(tzinfo=timezone.utc)
|
|
else:
|
|
end_dt = e.end
|
|
|
|
# Setze is_active auf False, wenn Termin vorbei ist
|
|
if end_dt and end_dt < now and e.is_active:
|
|
e.is_active = False
|
|
session.commit()
|
|
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
|
|
).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.
|
|
recurrence_exception = None
|
|
if exception_dates:
|
|
# Use event start time in UTC as baseline
|
|
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)
|
|
|
|
base_payload = {
|
|
"Id": str(e.id),
|
|
"GroupId": e.group_id,
|
|
"Subject": e.title,
|
|
"StartTime": e.start.isoformat() if e.start else None,
|
|
"EndTime": e.end.isoformat() if e.end else None,
|
|
"IsAllDay": False,
|
|
"MediaId": e.event_media_id,
|
|
"Type": e.event_type.value if e.event_type else None, # <-- Enum zu String!
|
|
"Icon": get_icon_for_type(e.event_type.value if e.event_type else None),
|
|
# Recurrence metadata
|
|
"RecurrenceRule": e.recurrence_rule,
|
|
"RecurrenceEnd": e.recurrence_end.isoformat() if e.recurrence_end else None,
|
|
"RecurrenceException": recurrence_exception,
|
|
"SkipHolidays": bool(getattr(e, 'skip_holidays', False)),
|
|
}
|
|
result.append(base_payload)
|
|
session.close()
|
|
return jsonify(result)
|
|
|
|
|
|
@events_bp.route("/<event_id>", methods=["DELETE"])
|
|
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
|
|
session.delete(event)
|
|
session.commit()
|
|
session.close()
|
|
return jsonify({"success": True})
|
|
|
|
|
|
@events_bp.route("", methods=["POST"])
|
|
def create_event():
|
|
data = request.json
|
|
session = Session()
|
|
|
|
# Pflichtfelder prüfen
|
|
required = ["group_id", "title", "description",
|
|
"start", "end", "event_type", "created_by"]
|
|
for field in required:
|
|
if field not in data:
|
|
return jsonify({"error": f"Missing field: {field}"}), 400
|
|
|
|
event_type = data["event_type"]
|
|
event_media_id = None
|
|
slideshow_interval = None
|
|
|
|
# Präsentation: event_media_id und slideshow_interval übernehmen
|
|
if event_type == "presentation":
|
|
event_media_id = data.get("event_media_id")
|
|
slideshow_interval = data.get("slideshow_interval")
|
|
if not event_media_id:
|
|
return jsonify({"error": "event_media_id required for presentation"}), 400
|
|
|
|
# Website: Webseite als EventMedia anlegen und ID übernehmen
|
|
if event_type == "website":
|
|
website_url = data.get("website_url")
|
|
if not website_url:
|
|
return jsonify({"error": "website_url required for website"}), 400
|
|
# EventMedia für Webseite anlegen
|
|
media = EventMedia(
|
|
media_type=MediaType.website,
|
|
url=website_url,
|
|
file_path=website_url
|
|
)
|
|
session.add(media)
|
|
session.commit()
|
|
event_media_id = media.id
|
|
|
|
# created_by aus den Daten holen, Default: None
|
|
created_by = data.get("created_by")
|
|
|
|
# Start- und Endzeit in UTC umwandeln, falls kein Zulu-Zeitstempel
|
|
start = datetime.fromisoformat(data["start"])
|
|
end = datetime.fromisoformat(data["end"])
|
|
if start.tzinfo is None:
|
|
start = start.astimezone(timezone.utc)
|
|
if end.tzinfo is None:
|
|
end = end.astimezone(timezone.utc)
|
|
|
|
# Determine skip_holidays from either camelCase or snake_case
|
|
skip_holidays_val = bool(data.get("skipHolidays")) or bool(data.get("skip_holidays"))
|
|
|
|
# Event anlegen
|
|
event = Event(
|
|
group_id=data["group_id"],
|
|
title=data["title"],
|
|
description=data["description"],
|
|
start=start,
|
|
end=end,
|
|
event_type=event_type,
|
|
is_active=True,
|
|
event_media_id=event_media_id,
|
|
slideshow_interval=slideshow_interval,
|
|
created_by=created_by,
|
|
# Recurrence
|
|
recurrence_rule=data.get("recurrence_rule"),
|
|
skip_holidays=skip_holidays_val,
|
|
recurrence_end=(datetime.fromisoformat(data["recurrence_end"]) if data.get("recurrence_end") else None),
|
|
)
|
|
session.add(event)
|
|
session.commit()
|
|
|
|
# --- Holiday exception creation (backend) ---
|
|
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 for this event
|
|
session.query(EventException).filter_by(event_id=ev.id).delete()
|
|
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
|
|
he = h.end_date
|
|
d = hs
|
|
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:
|
|
session.add(EventException(event_id=ev.id, exception_date=occ_date, is_skipped=True))
|
|
session.commit()
|
|
|
|
regenerate_event_exceptions(event)
|
|
|
|
return jsonify({"success": True, "event_id": event.id})
|
|
|
|
|
|
@events_bp.route("/<event_id>", methods=["PUT"])
|
|
def update_event(event_id):
|
|
data = request.json
|
|
session = Session()
|
|
event = session.query(Event).filter_by(id=event_id).first()
|
|
if not event:
|
|
session.close()
|
|
return jsonify({"error": "Termin nicht gefunden"}), 404
|
|
|
|
event.title = data.get("title", event.title)
|
|
event.description = data.get("description", event.description)
|
|
event.start = datetime.fromisoformat(
|
|
data["start"]) if "start" in data else event.start
|
|
event.end = datetime.fromisoformat(
|
|
data["end"]) if "end" in data else event.end
|
|
event.event_type = data.get("event_type", event.event_type)
|
|
event.event_media_id = data.get("event_media_id", event.event_media_id)
|
|
event.slideshow_interval = data.get("slideshow_interval", event.slideshow_interval)
|
|
event.created_by = data.get("created_by", event.created_by)
|
|
# Track previous values to decide on exception regeneration
|
|
prev_rule = event.recurrence_rule
|
|
prev_end = event.recurrence_end
|
|
prev_skip = bool(getattr(event, 'skip_holidays', False))
|
|
|
|
# Recurrence updates
|
|
if "recurrence_rule" in data:
|
|
event.recurrence_rule = data.get("recurrence_rule")
|
|
if "recurrence_end" in data:
|
|
rec_end_val = data.get("recurrence_end")
|
|
event.recurrence_end = datetime.fromisoformat(rec_end_val) if rec_end_val else None
|
|
# Skip holidays can be updated independently
|
|
if "skipHolidays" in data or "skip_holidays" in data:
|
|
event.skip_holidays = bool(data.get("skipHolidays") or data.get("skip_holidays"))
|
|
|
|
session.commit()
|
|
|
|
# Regenerate exceptions if any relevant field changed
|
|
need_regen = (
|
|
prev_rule != event.recurrence_rule or
|
|
prev_end != event.recurrence_end or
|
|
prev_skip != bool(getattr(event, 'skip_holidays', False))
|
|
)
|
|
if need_regen:
|
|
# Re-use helper from create route
|
|
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()
|
|
session.commit()
|
|
if not (ev.skip_holidays and ev.recurrence_rule):
|
|
return
|
|
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)
|
|
holiday_dates = set()
|
|
for h in holidays:
|
|
hs = h.start_date
|
|
he = h.end_date
|
|
d = hs
|
|
while d <= he:
|
|
holiday_dates.add(d)
|
|
d = d + timedelta(days=1)
|
|
for occ_start in r.between(window_start, window_end, inc=True):
|
|
occ_date = occ_start.date()
|
|
if occ_date in holiday_dates:
|
|
session.add(EventException(event_id=ev.id, exception_date=occ_date, is_skipped=True))
|
|
session.commit()
|
|
|
|
regenerate_event_exceptions(event)
|
|
|
|
event_id_return = event.id # <-- ID vor session.close() speichern!
|
|
session.close()
|
|
return jsonify({"success": True, "event_id": event_id_return})
|