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
COPY kantine2ical.py app.py ./
COPY templates/ templates/
RUN useradd -m 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.
**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).
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
```
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).
4. In Google Kalender: „Andere Kalender hinzufügen“ → „Von URL“ → obige URL eintragen.
3. Im Browser die **Startseite** aufrufen: `http://<host>:5000/` dort die Abo-URL kopieren und die Anleitung nutzen.
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):**
@@ -86,7 +95,8 @@ docker build -t 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:**
```bash

49
app.py
View File

@@ -8,8 +8,10 @@ import logging
import os
import threading
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
@@ -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)
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__)
# 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()
_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
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.")
@@ -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("/")
def index() -> Response:
"""Redirect auf calendar.ics oder gleiche Antwort wie /calendar.ics."""
return calendar_ics()
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:

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>