Files
infoscreen/server/routes/events.py
RobbStarkAustria 773628c324 feat(events): reliable holiday skipping for recurrences + UI badge; clean logs
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
2025-10-12 12:00:43 +00:00

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})