#!/usr/bin/env python3 """ Flask-Server: Kantinen-Speiseplan als abonnierbare iCal-URL. Täglicher Hintergrund-Refresh lädt neue PDFs und aktualisiert den Kalender. """ import logging import os import threading import time from datetime import datetime from zoneinfo import ZoneInfo from flask import Flask, Response, render_template, request from werkzeug.middleware.proxy_fix import ProxyFix from kantine2ical import BASE_URL, empty_ical_bytes, refresh_speiseplan # Konfiguration (Umgebungsvariablen mit Fallback) KANTINE_BASE_URL = os.environ.get("KANTINE_BASE_URL", BASE_URL) REFRESH_INTERVAL_SECONDS = int(os.environ.get("REFRESH_INTERVAL_SECONDS", "43200")) # 12h # Öffentliche Basis-URL (z. B. https://kantine.elpatron.me), wenn Proxy keine X-Forwarded-* sendet PUBLIC_URL = os.environ.get("PUBLIC_URL", "").strip().rstrip("/") # Template-Ordner immer relativ zu dieser Datei (funktioniert mit Gunicorn/Docker) _template_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") app = Flask(__name__, template_folder=_template_dir) # Hinter Reverse-Proxy (HTTPS): X-Forwarded-Proto und X-Forwarded-Host nutzen app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) _log = logging.getLogger(__name__) # Cache: zuletzt gültige iCal-Bytes; Zeitpunkt der letzten Aktualisierung _ical_cache: bytes = empty_ical_bytes() _last_refresh_at: datetime | None = None _cache_lock = threading.Lock() _TIMEZONE = ZoneInfo("Europe/Berlin") def _do_refresh() -> None: """Refresh ausführen und bei Erfolg Cache aktualisieren.""" global _ical_cache, _last_refresh_at result = refresh_speiseplan(KANTINE_BASE_URL) if result is not None: _, ical_bytes = result with _cache_lock: _ical_cache = ical_bytes _last_refresh_at = datetime.now(_TIMEZONE) _log.info("Speiseplan-Refresh erfolgreich, Cache aktualisiert.") else: _log.warning("Speiseplan-Refresh fehlgeschlagen oder keine Daten; Cache unverändert.") def _refresh_loop() -> None: """Hintergrund-Thread: alle REFRESH_INTERVAL_SECONDS einen Refresh ausführen.""" while True: time.sleep(REFRESH_INTERVAL_SECONDS) try: _do_refresh() except Exception as e: _log.exception("Refresh-Thread: %s", e) # Beim Import: einmal Refresh, Hintergrund-Thread starten (gilt auch für Gunicorn) logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) _log.info("Starte Speiseplan-Refresh beim Start ...") _do_refresh() _refresh_thread = threading.Thread(target=_refresh_loop, daemon=True) _refresh_thread.start() _log.info("Hintergrund-Refresh alle %s Sekunden.", REFRESH_INTERVAL_SECONDS) @app.route("/refresh") def refresh() -> Response: """Manuellen Refresh auslösen (z. B. nach neuem Speiseplan auf kantine-bhz.de).""" _log.info("Manueller Refresh ausgelöst") _do_refresh() last = _format_last_refresh() return Response( f"Refresh ausgeführt. Zuletzt aktualisiert: {last or '–'}.\n", mimetype="text/plain; charset=utf-8", ) @app.route("/calendar.ics") def calendar_ics() -> Response: """iCal-Kalender ausliefern (für Abo-URL z. B. in Google Kalender).""" with _cache_lock: data = _ical_cache return Response( data, mimetype="text/calendar; charset=utf-8", headers={"Content-Disposition": 'attachment; filename="kantine_speiseplan.ics"'}, ) def _format_last_refresh() -> str | None: """Zeitpunkt der letzten Aktualisierung formatiert (z. B. 29.01.2025, 14:32 Uhr).""" with _cache_lock: t = _last_refresh_at if t is None: return None return t.strftime("%d.%m.%Y, %H:%M") + " Uhr" @app.route("/") def index(): """Startseite mit Anleitung zur iCal-Einbettung (Google und andere).""" base = PUBLIC_URL or request.url_root.rstrip("/") calendar_url = f"{base}/calendar.ics" last_refresh_str = _format_last_refresh() try: return render_template( "index.html", calendar_url=calendar_url, last_refresh_str=last_refresh_str, ) except Exception as e: _log.exception("Template index.html: %s", e) return ( f'Speiseplan iCal' f'

Speiseplan Kantine BHZ Kiel-Wik

' f'

Zuletzt aktualisiert: {last_refresh_str or "noch nicht"}.

' f'

Abo-URL: {calendar_url}

' f'

In Google Kalender: Andere Kalender → Von URL → obige URL einfügen.

', 200, {"Content-Type": "text/html; charset=utf-8"}, ) def main() -> None: """Flask-Entwicklungsserver starten (Refresh und Thread laufen bereits beim Import).""" app.run(host="0.0.0.0", port=5000) if __name__ == "__main__": main()