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
|
||||
|
||||
COPY kantine2ical.py app.py ./
|
||||
COPY templates/ templates/
|
||||
|
||||
RUN useradd -m 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.
|
||||
|
||||
**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
49
app.py
@@ -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
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