140 lines
4.9 KiB
Python
140 lines
4.9 KiB
Python
#!/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'<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><title>Speiseplan iCal</title></head>'
|
||
f'<body><h1>Speiseplan Kantine BHZ Kiel-Wik</h1>'
|
||
f'<p>Zuletzt aktualisiert: {last_refresh_str or "noch nicht"}.</p>'
|
||
f'<p>Abo-URL: <a href="{calendar_url}">{calendar_url}</a></p>'
|
||
f'<p>In Google Kalender: Andere Kalender → Von URL → obige URL einfügen.</p></body></html>',
|
||
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()
|