Startseite mit Anleitung, letzte Aktualisierung, README ergänzt

This commit is contained in:
2026-01-29 13:30:42 +01:00
parent bb43f7c63a
commit abd64fd961
4 changed files with 344 additions and 10 deletions

View File

@@ -6,6 +6,7 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY kantine2ical.py app.py ./ COPY kantine2ical.py app.py ./
COPY templates/ templates/
RUN useradd -m appuser RUN useradd -m appuser
USER appuser USER appuser

View File

@@ -58,6 +58,14 @@ Die Termine erscheinen mit der Zeitzone Europe/Berlin um 12:00 Uhr mit allen fü
Der Speiseplan kann als **externer Kalender** per URL angeboten werden. Ein Flask-Server liefert die iCal-Daten; Google Kalender und andere Clients können die URL direkt abonnieren. Im Hintergrund wird täglich nach neuen Speiseplan-PDFs gesucht und der Kalender aktualisiert. Der Speiseplan kann als **externer Kalender** per URL angeboten werden. Ein Flask-Server liefert die iCal-Daten; Google Kalender und andere Clients können die URL direkt abonnieren. Im Hintergrund wird täglich nach neuen Speiseplan-PDFs gesucht und der Kalender aktualisiert.
**Startseite:** Unter der Stamm-URL (`/`) liefert der Server eine **Startseite** mit:
- der Abo-URL zum Kopieren (inkl. „Kopieren“-Button),
- einer Anleitung zur Einbettung in **Google Kalender** (Schritt für Schritt),
- Kurzhinweisen für **andere Kalender-Apps** (Outlook, Apple Kalender, Thunderbird, Android/iOS),
- der Angabe, **wann die Speisepläne zuletzt aktualisiert** wurden.
Die eigentliche iCal-Datei für Abos und direkten Download ist unter `/calendar.ics` erreichbar.
**Voraussetzung:** Für „Von URL hinzufügen“ in Google Kalender muss die Server-URL von außen erreichbar sein (öffentliche IP, Reverse-Proxy oder z. B. ngrok für Tests). **Voraussetzung:** Für „Von URL hinzufügen“ in Google Kalender muss die Server-URL von außen erreichbar sein (öffentliche IP, Reverse-Proxy oder z. B. ngrok für Tests).
1. Abhängigkeiten installieren (inkl. Flask): `pip install -r requirements.txt` 1. Abhängigkeiten installieren (inkl. Flask): `pip install -r requirements.txt`
@@ -66,8 +74,9 @@ Der Speiseplan kann als **externer Kalender** per URL angeboten werden. Ein Flas
python app.py python app.py
``` ```
Oder mit Flask-CLI: `flask --app app run --host 0.0.0.0 --port 5000` Oder mit Flask-CLI: `flask --app app run --host 0.0.0.0 --port 5000`
3. Abo-URL für Google Kalender: `http://<host>:5000/calendar.ics` (bzw. Port 5000 durch Ihren Host/Port ersetzen). 3. Im Browser die **Startseite** aufrufen: `http://<host>:5000/` dort die Abo-URL kopieren und die Anleitung nutzen.
4. In Google Kalender: „Andere Kalender hinzufügen“ → „Von URL“ → obige URL eintragen. 4. Direkte Abo-URL: `http://<host>:5000/calendar.ics` (bzw. Port durch Ihren Host ersetzen).
5. In Google Kalender: „Andere Kalender hinzufügen“ → „Von URL“ → Abo-URL eintragen.
**Konfiguration (optional, Umgebungsvariablen):** **Konfiguration (optional, Umgebungsvariablen):**
@@ -86,7 +95,8 @@ docker build -t kantine2ical .
docker run -p 8000:8000 kantine2ical docker run -p 8000:8000 kantine2ical
``` ```
Abo-URL: `http://<host>:8000/calendar.ics` - **Startseite** (Anleitung + Abo-URL): `http://<host>:8000/`
- **iCal-Abo:** `http://<host>:8000/calendar.ics`
**Mit Docker Compose:** **Mit Docker Compose:**
```bash ```bash

49
app.py
View File

@@ -8,8 +8,10 @@ import logging
import os import os
import threading import threading
import time import time
from datetime import datetime
from zoneinfo import ZoneInfo
from flask import Flask, Response from flask import Flask, Response, render_template, request
from kantine2ical import BASE_URL, empty_ical_bytes, refresh_speiseplan from kantine2ical import BASE_URL, empty_ical_bytes, refresh_speiseplan
@@ -17,21 +19,26 @@ from kantine2ical import BASE_URL, empty_ical_bytes, refresh_speiseplan
KANTINE_BASE_URL = os.environ.get("KANTINE_BASE_URL", BASE_URL) KANTINE_BASE_URL = os.environ.get("KANTINE_BASE_URL", BASE_URL)
REFRESH_INTERVAL_SECONDS = int(os.environ.get("REFRESH_INTERVAL_SECONDS", "86400")) # 24h REFRESH_INTERVAL_SECONDS = int(os.environ.get("REFRESH_INTERVAL_SECONDS", "86400")) # 24h
app = Flask(__name__) # 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)
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
# Cache: zuletzt gültige iCal-Bytes; Lock für Zugriff # Cache: zuletzt gültige iCal-Bytes; Zeitpunkt der letzten Aktualisierung
_ical_cache: bytes = empty_ical_bytes() _ical_cache: bytes = empty_ical_bytes()
_last_refresh_at: datetime | None = None
_cache_lock = threading.Lock() _cache_lock = threading.Lock()
_TIMEZONE = ZoneInfo("Europe/Berlin")
def _do_refresh() -> None: def _do_refresh() -> None:
"""Refresh ausführen und bei Erfolg Cache aktualisieren.""" """Refresh ausführen und bei Erfolg Cache aktualisieren."""
global _ical_cache global _ical_cache, _last_refresh_at
result = refresh_speiseplan(KANTINE_BASE_URL) result = refresh_speiseplan(KANTINE_BASE_URL)
if result is not None: if result is not None:
_, ical_bytes = result _, ical_bytes = result
with _cache_lock: with _cache_lock:
_ical_cache = ical_bytes _ical_cache = ical_bytes
_last_refresh_at = datetime.now(_TIMEZONE)
_log.info("Speiseplan-Refresh erfolgreich, Cache aktualisiert.") _log.info("Speiseplan-Refresh erfolgreich, Cache aktualisiert.")
else: else:
_log.warning("Speiseplan-Refresh fehlgeschlagen oder keine Daten; Cache unverändert.") _log.warning("Speiseplan-Refresh fehlgeschlagen oder keine Daten; Cache unverändert.")
@@ -68,10 +75,38 @@ def calendar_ics() -> Response:
) )
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("/") @app.route("/")
def index() -> Response: def index():
"""Redirect auf calendar.ics oder gleiche Antwort wie /calendar.ics.""" """Startseite mit Anleitung zur iCal-Einbettung (Google und andere)."""
return calendar_ics() 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: def main() -> None:

288
templates/index.html Normal file
View File

@@ -0,0 +1,288 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Speiseplan Kantine BHZ Kiel-Wik iCal-Abo</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0f1419;
--surface: #1a2332;
--text: #e6edf3;
--text-muted: #8b949e;
--accent: #58a6ff;
--accent-soft: rgba(88, 166, 255, 0.15);
--border: #30363d;
--success: #3fb950;
--radius: 12px;
--font: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font);
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
}
.wrap {
max-width: 640px;
margin: 0 auto;
padding: 2rem 1.25rem;
}
header {
margin-bottom: 2.5rem;
}
h1 {
font-size: clamp(1.5rem, 4vw, 1.75rem);
font-weight: 700;
margin: 0 0 0.5rem;
letter-spacing: -0.02em;
}
.subtitle {
color: var(--text-muted);
font-size: 0.95rem;
margin: 0;
}
.last-refresh {
color: var(--text-muted);
font-size: 0.875rem;
margin: 0.5rem 0 0;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem 1.5rem;
margin-bottom: 1.5rem;
}
.card h2 {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card h2 .num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
background: var(--accent-soft);
color: var(--accent);
border-radius: 6px;
font-size: 0.85rem;
}
.url-box {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.url-box input {
flex: 1;
min-width: 0;
background: none;
border: none;
color: var(--text);
font-family: ui-monospace, monospace;
font-size: 0.85rem;
}
.url-box input:focus {
outline: none;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
font-size: 0.9rem;
font-weight: 500;
font-family: var(--font);
border-radius: 8px;
border: none;
cursor: pointer;
background: var(--accent);
color: #fff;
white-space: nowrap;
}
.btn:hover {
filter: brightness(1.1);
}
.btn:active {
transform: scale(0.98);
}
.btn.copy-done {
background: var(--success);
}
ol.steps {
margin: 0;
padding-left: 1.25rem;
color: var(--text-muted);
font-size: 0.95rem;
}
ol.steps li {
margin-bottom: 0.5rem;
}
ol.steps li strong {
color: var(--text);
}
.provider-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
.provider {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
}
.provider strong {
display: block;
margin-bottom: 0.35rem;
font-size: 0.95rem;
}
.provider p {
margin: 0;
font-size: 0.85rem;
color: var(--text-muted);
}
footer {
margin-top: 2.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
font-size: 0.85rem;
color: var(--text-muted);
}
footer a {
color: var(--accent);
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Speiseplan Kantine BHZ Kiel-Wik</h1>
<p class="subtitle">iCal-Abo für Ihren Kalender täglich aktualisiert, alle Tagesgerichte (I.V.) um 12:00 Uhr.</p>
{% if last_refresh_str %}
<p class="last-refresh">Zuletzt aktualisiert: {{ last_refresh_str }}</p>
{% else %}
<p class="last-refresh">Speisepläne wurden noch nicht aktualisiert.</p>
{% endif %}
</header>
<section class="card">
<h2><span class="num">1</span> Abo-URL kopieren</h2>
<p style="margin: 0 0 1rem; font-size: 0.95rem; color: var(--text-muted);">Diese URL in Ihrem Kalender als „Abonnement“ oder „Von URL hinzufügen“ eintragen:</p>
<div class="url-box">
<input type="text" id="ical-url" value="{{ calendar_url }}" readonly aria-label="iCal-URL">
<button type="button" class="btn" id="copy-btn" aria-label="URL kopieren">Kopieren</button>
</div>
</section>
<section class="card">
<h2><span class="num">2</span> Google Kalender</h2>
<ol class="steps">
<li><strong>calendar.google.com</strong> öffnen</li>
<li>Rechts neben „Meine Kalender“ auf <strong>+</strong> klicken → <strong>Von URL</strong></li>
<li>Die obige Abo-URL einfügen und <strong>Kalender hinzufügen</strong> wählen</li>
<li>Der Speiseplan erscheint als eigener Kalender und wird automatisch aktualisiert</li>
</ol>
</section>
<section class="card">
<h2><span class="num">3</span> Andere Kalender-Apps</h2>
<div class="provider-grid">
<div class="provider">
<strong>Outlook (Web)</strong>
<p>Kalender → Einstellungen → Kalender hinzufügen → Abonnement; Abo-URL einfügen.</p>
</div>
<div class="provider">
<strong>Apple Kalender</strong>
<p>Kalender → Ablage → Neues Kalender-Abonnement; Abo-URL einfügen.</p>
</div>
<div class="provider">
<strong>Thunderbird</strong>
<p>Kalender → Neuer Kalender → Im Netzwerk; Abo-URL einfügen.</p>
</div>
<div class="provider">
<strong>Android / iOS</strong>
<p>In den Einstellungen der Kalender-App „Kalender hinzufügen“ / „Abo per URL“; Abo-URL einfügen.</p>
</div>
</div>
</section>
<footer>
Quelle: <a href="http://kantine-bhz.de" target="_blank" rel="noopener">kantine-bhz.de</a>. Der Speiseplan wird einmal täglich aktualisiert. Direkter Kalender-Download: <a href="{{ calendar_url }}">calendar.ics</a>.
</footer>
</div>
<script>
(function() {
var input = document.getElementById('ical-url');
var btn = document.getElementById('copy-btn');
if (!input || !btn) return;
btn.addEventListener('click', function() {
input.select();
input.setSelectionRange(0, 99999);
try {
navigator.clipboard.writeText(input.value);
btn.textContent = 'Kopiert';
btn.classList.add('copy-done');
setTimeout(function() {
btn.textContent = 'Kopieren';
btn.classList.remove('copy-done');
}, 2000);
} catch (e) {
btn.textContent = 'Bitte manuell kopieren';
}
});
})();
</script>
</body>
</html>