#!/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 date, 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; strukturierte Daten (Datum → Gerichte); Zeitpunkt der letzten Aktualisierung
_ical_cache: bytes = empty_ical_bytes()
_by_date_cache: dict[date, list[str]] | None = None
_last_refresh_at: datetime | None = None
_cache_lock = threading.Lock()
_TIMEZONE = ZoneInfo("Europe/Berlin")
_WEEKDAY_NAMES = ("Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag")
def _do_refresh() -> None:
"""Refresh ausführen und bei Erfolg Cache aktualisieren."""
global _ical_cache, _by_date_cache, _last_refresh_at
result = refresh_speiseplan(KANTINE_BASE_URL)
if result is not None:
by_date, ical_bytes = result
with _cache_lock:
_ical_cache = ical_bytes
_by_date_cache = by_date
_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"'},
)
@app.route("/sitemap.xml")
def sitemap() -> Response:
"""Einfache Sitemap mit den wichtigsten URLs der Anwendung."""
base = PUBLIC_URL or request.url_root.rstrip("/")
urls = [f"{base}/", f"{base}/calendar.ics"]
body = [
'',
'
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()