Startseite mit Anleitung, letzte Aktualisierung, README ergänzt
This commit is contained in:
@@ -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
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -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
49
app.py
@@ -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
288
templates/index.html
Normal 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>
|
||||||
Reference in New Issue
Block a user