from flask import Blueprint, request, jsonify from server.permissions import editor_or_higher from server.database import Session from models.models import Event, EventMedia, MediaType, EventException, SystemSetting 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 # Auto-deactivate only non-recurring events past their end. # Recurring events remain active until their RecurrenceEnd (UNTIL) has passed. if e.is_active: if e.recurrence_rule: # For recurring, deactivate only when recurrence_end exists and is in the past rec_end = e.recurrence_end if rec_end and rec_end.tzinfo is None: rec_end = rec_end.replace(tzinfo=timezone.utc) if rec_end and rec_end < now: e.is_active = False session.commit() else: # Non-recurring: deactivate when end is in the past if end_dt and end_dt < now: e.is_active = False session.commit() if not (show_inactive or e.is_active): continue # Gather exceptions for this event all_exceptions = session.query(EventException).filter( EventException.event_id == e.id ).all() # Build RecurrenceException (EXDATE) tokens for skipped occurrences only # (detached occurrences are now real Event rows, not synthetic) recurrence_exception = None if all_exceptions: base_start = e.start.astimezone(UTC) if e.start.tzinfo else e.start.replace(tzinfo=UTC) tokens = [] for ex in all_exceptions: if ex.is_skipped: exd = ex.exception_date # Create the EXDATE timestamp in Syncfusion's expected format # Use the exact time of the occurrence that would have happened occ_dt = datetime( exd.year, exd.month, exd.day, base_start.hour, base_start.minute, base_start.second, tzinfo=UTC ) # Format as compact ISO without separators (yyyyMMddThhmmssZ) - RFC 5545 format 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, "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) # 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("/", 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("/", methods=["DELETE"]) # delete series or single event @editor_or_higher 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("//occurrences/", methods=["DELETE"]) # skip single occurrence @editor_or_higher 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("//occurrences//detach", methods=["POST"]) # detach single occurrence into standalone event @editor_or_higher 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"]) @editor_or_higher 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 # WebUntis: URL aus System-Einstellungen holen und EventMedia anlegen if event_type == "webuntis": # Hole WebUntis-URL aus Systemeinstellungen (verwendet supplement_table_url) webuntis_setting = session.query(SystemSetting).filter_by(key='supplement_table_url').first() webuntis_url = webuntis_setting.value if webuntis_setting else '' if not webuntis_url: return jsonify({"error": "WebUntis / Supplement table URL not configured in system settings"}), 400 # EventMedia für WebUntis anlegen media = EventMedia( media_type=MediaType.website, url=webuntis_url, file_path=webuntis_url ) session.add(media) session.commit() event_media_id = media.id # Video: event_media_id und Video-Einstellungen übernehmen autoplay = None loop = None volume = None if event_type == "video": event_media_id = data.get("event_media_id") if not event_media_id: return jsonify({"error": "event_media_id required for video"}), 400 # Get video-specific settings with defaults autoplay = data.get("autoplay", True) loop = data.get("loop", False) volume = data.get("volume", 0.8) # 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, autoplay=autoplay, loop=loop, volume=volume, 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 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 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("/", methods=["PUT"]) # update series or single event @editor_or_higher 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) # Video-specific fields if "autoplay" in data: event.autoplay = data.get("autoplay") if "loop" in data: event.loop = data.get("loop") if "volume" in data: event.volume = data.get("volume") 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 (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 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 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 success with event id event_id_return = event.id session.close() return jsonify({"success": True, "event_id": event_id_return})