Files
kantine2ical/app.py

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()