122 lines
4.3 KiB
Python
122 lines
4.3 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", "86400")) # 24h
|
|
|
|
# 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)
|
|
_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("/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 = 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()
|