Compare commits

...
5 Commits
Author SHA1 Message Date
stefankoelle 2fbfe31ffb new optional notify feature 2026-08-04 13:48:07 +02:00
stefankoelle 1b5e09a342 Fix: korrigiere Code-Bugs und Dokumentation
- api/main.py: index() Route übernimmt Request-Objekt korrekt (statt {}),
  calendar_label Query-Parameter implementiert (dynamische WHERE-Klausel)
- docker-compose.yml: DB_BOOTSTRAP/DB_ROOT_USER/DB_ROOT_PASSWORD Variablen
  für calendar-sync Service hinzugefügt (waren dokumentiert, aber nie übergeben)
- .env.example: DB_BOOTSTRAP, API_PORT, TIMEZONE hinzugefügt
- AGENTS.md: Zeilennummer sync.py:91→83, gemischte Deutsch/China-Sprache
  bereinigt
- SPEC.md: --entrypoint→command, API_HOST entfernt (nicht implementiert),
  TIMEZONE hinzugefügt, JSON-Beispiele um timezone-Feld erweitert,
  Docker-Compose-Beispiel und Projektstruktur aktualisiert,
  Search als implementiert markiert
- README.md: TIMEZONE und API_PORT in Konfigtationstabelle,
  timezone im JSON-Beispiel
2026-08-02 08:20:21 +02:00
stefankoelle 25a38f886d fix: add missing timezone field to EventsListResponse API response 2026-08-02 07:57:10 +02:00
stefankoelle a84e27844b feat: add TIMEZONE environment variable to docker-compose.yml for API service 2026-08-02 07:52:51 +02:00
stefankoelleandopenhands d925ac1253 fix: display local timezone in API and web frontend instead of GMT
- Add TIMEZONE environment variable support (default: UTC)
- Convert naive UTC datetimes from database to local timezone in API responses
- Add timezone field to EventResponse and EventsListResponse Pydantic models
- Update web template to display timezone information
- Update PLAN.md to document timezone changes

This fixes the issue where events at 8:00 UTC would appear as 6:00 GMT by displaying times in the correct local timezone.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-02 07:33:20 +02:00
9 changed files with 358 additions and 42 deletions
+22
View File
@@ -16,3 +16,25 @@ LOG_LEVEL=INFO
# Optional: Healthchecks.io / Uptime Kuma URL (wird nach jedem Sync gepingt)
# HEALTHCHECK_URL=https://hc-ping.com/DEINE_UUID
# Optional: Database Bootstrap (erstellt DB und User beim Start)
# DB_BOOTSTRAP=true
# DB_ROOT_USER=root
# DB_ROOT_PASSWORD=dein_root_passwort
# API-Server Port (für Web-UI + REST API)
API_PORT=8000
# Zeitzone für die API/Web-UI (Default: UTC)
# TIMEZONE=Europe/Berlin
# Optional: Tägliche E-Mail Benachrichtigung um 6 Uhr
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=user@example.com
# SMTP_PASSWORD=dein_passwort
# SMTP_FROM=user@example.com
# SMTP_USE_TLS=true
# NOTIFY_EMAIL=empfaenger@example.com
# NOTIFY_TIME=6
# NOTIFY_TIMEZONE=Europe/Berlin
+10 -2
View File
@@ -48,7 +48,7 @@ Die API (`api/main.py`) ist eine separarte FastAPI-App die als eigenständiger C
### Datenbank-Schema
Schema wird in `ensure_schema()` per `CREATE TABLE IF NOT EXISTS` erstellt. Bei Schema-Änderungen:
- `ensure_schema()` in `sync.py:91` anpassen
- `ensure_schema()` in `sync.py:83` anpassen
- MariaDB-kompatibles SQL verwenden (kein PostgreSQL-Specific)
- Indexe für Performance bedenken
@@ -56,11 +56,19 @@ Schema wird in `ensure_schema()` per `CREATE TABLE IF NOT EXISTS` erstellt. Bei
Verwendet `recurring_ical_events` Bibliothek für RRULE/EXDATE/RECURRENCE-ID Expansion. Fenster wird über `WINDOW_PAST_DAYS`/`WINDOW_FUTURE_DAYS` gesteuert.
### UTC-Normalisierung
Alle Zeiten werden in naive UTC datetime konvertiert (`to_naive_utc()`). Bei Datumsänderungen sicherstellen, dass Zeitzone korrekt处理 wird.
Alle Zeiten werden in naive UTC datetime konvertiert (`to_naive_utc()`). Bei Datumsänderungen sicherstellen, dass die Zeitzone korrekt verarbeitet wird.
### Soft-Delete
Events werden nicht gelöscht, sondern mit `deleted=1` markiert (`mark_missing_as_deleted()`).
### Tägliche E-Mail-Benachrichtigung
Sendet täglich um konfigurierte Uhrzeit eine HTML-Email mit anstehenden Terminen:
- Nur Termine mit Uhrzeit (`all_day=0`), keine Ganztagstermine
- Subject: Bei 1 Termin direkt "Kalender heute: HH:MM - Termin", bei mehreren "Kalender heute: X Termine"
- Tracking via `daily_notification_log` Tabelle (verhindert Doppelversand)
- Konfiguration über SMTP_* und NOTIFY_* Umgebungsvariablen
- Funktionen: `get_today_events()`, `should_notify()`, `send_notification()`, `log_notification()`
## Entwicklung
### Lokaler Test
+19
View File
@@ -128,6 +128,25 @@ jinja2==3.1.4
## Offene Punkte
- [x] Zeitzonen-Korrektur in API und Web-UI (statt GMT → lokaler Zeitzone)
- [x] Notwendigkeit eines timezone-Feldes in der DB für korrekte Speicherung
- [x] Zeitstempel-Speicherung mit korrekter Zeitzone-Feldunterstützung in DB
- [x] DB Bootstrap - bleibt in sync.py
- [x] Template Styling - einfaches CSS, kein Framework
- [x] Search - optionaler Suchbegriff auf Event-Titel (API + Frontend)
## Richtig gelöst: Keine DB-Zeitzone-Speicherung nötig
Da MySQL/MariaDB naive DATETIME-Werte speichert (ohne Zeitzone), muss die Zeitzone-Zuweisung auf der API/Web-UI-Seite erfolgen. Dies ist korrekt, da:
1. **Datenbank-Speicherung**: MariaDB DATETIME-Spalten können naive Datetimes speichern (alle in einem Standard)
2. **Zeitzone-Wiederherstellung**: Die API/Frontend-Komponenten können naive Datetimes in die korrekte Benutzer-Zeitzone konvertieren, wenn sie benötigt werden
3. **Vereinfachte Architektur**: Keine komplexe DB-Schema-Änderung erforderlich
**Lösung**: Konvertieren Sie naive UTC-Daten aus DB → lokale Zeitzone im API/Web-UI durch Hinzufügen von `timezone`-Feld in Response-Modell.
**Änderungen**:
- Fügen Sie ein `timezone`-Feld zu `EventResponse` und `EventsListResponse` hinzu
- Konvertieren Sie Datetimes in API: `datetime.now(timezone.utc)``to_local_timezone()`
- Passen Sie `row_to_event` an: Konvertieren Sie naive UTC → Benutzer-Zeitzone
- Aktualisieren Sie das Template: Verwenden Sie den Zeitzonennamen für Formatierung
+40 -4
View File
@@ -13,6 +13,7 @@ Läuft als Docker Container, pollt periodisch einen privaten Google Calendar ICS
- Zeitfenster-konfiguration für Vergangenheit/Future (standardmäßig -90 Tage / +365 Tage)
- Web-UI zur Anzeige anstehender Termine mit Suchfunktion
- REST API für programmsprachigen Zugriff auf Kalenderdaten
- Optionale tägliche E-Mail-Benachrichtigung um konfigurierte Uhrzeit
## Voraussetzungen
@@ -70,6 +71,30 @@ Läuft als Docker Container, pollt periodisch einen privaten Google Calendar ICS
| `DB_BOOTSTRAP` | `false` | DB + User beim Start erstellen |
| `DB_ROOT_USER` | - | Root-User fürs Bootstrap |
| `DB_ROOT_PASSWORD` | - | Root-Passwort fürs Bootstrap |
| `API_PORT` | `8000` | Port für den API/Web-UI Container |
| `TIMEZONE` | `UTC` | Zeitzone für API/Web-UI Anzeige (z.B. `Europe/Berlin`) |
### Optionale E-Mail-Benachrichtigung
Sendet täglich eine HTML-Email mit den anstehenden Terminen. Wird aktiviert wenn `SMTP_HOST` und `NOTIFY_EMAIL` gesetzt sind.
| Variable | Default | Beschreibung |
|----------|---------|--------------|
| `SMTP_HOST` | - | SMTP Server Hostname |
| `SMTP_PORT` | `587` | SMTP Server Port |
| `SMTP_USER` | - | SMTP Login Username |
| `SMTP_PASSWORD` | - | SMTP Login Passwort |
| `SMTP_FROM` | - | Absender E-Mail Adresse |
| `SMTP_USE_TLS` | `true` | TLS verschlüsselung nutzen |
| `NOTIFY_EMAIL` | - | Empfänger E-Mail Adresse |
| `NOTIFY_TIME` | `6` | Uhrzeit für Benachrichtigung (Stunde, 0-23) |
| `NOTIFY_TIMEZONE` | `Europe/Berlin` | Zeitzone für die Benachrichtigung |
**Subject-Logik:**
- 1 Termin: `Kalender heute: 09:00 - Meeting mit Team`
- 2+ Termine: `Kalender heute: 3 Termine`
**Hinweis:** Ganztagstermine werden nicht in der Benachrichtigung berücksichtigt.
## Datenbank-Schema
@@ -94,6 +119,15 @@ Tabelle `calendar_events`:
| `created_at` | DATETIME | Erstellungszeitpunkt |
| `updated_at` | DATETIME | Letzte Änderung |
Tabelle `daily_notification_log` (optional, für E-Mail-Benachrichtigung):
| Spalte | Typ | Beschreibung |
|--------|-----|--------------|
| `id` | INT PK | Auto-Increment |
| `notify_date` | DATE | Datum der Benachrichtigung |
| `sent_at` | DATETIME | Zeitpunkt des Versands |
| `event_count` | INT | Anzahl Termine in der Email |
## Web-UI & API
Das Projekt enthält eine FastAPI-basierte Webanwendung die als separater Container (`calendar-api`) läuft und auf Port `8000` erreichbar ist.
@@ -132,12 +166,14 @@ curl http://localhost:8000/api/events/42
"location": "Konferenzraum 1",
"start_at": "2025-01-15T10:00:00",
"end_at": "2025-01-15T11:00:00",
"all_day": false,
"status": "CONFIRMED"
}
"all_day": false,
"status": "CONFIRMED",
"timezone": "Europe/Berlin"
}
],
"count": 1,
"query_time": "2025-01-15T09:30:00Z"
"query_time": "2025-01-15T09:30:00Z",
"timezone": "Europe/Berlin"
}
```
+17 -9
View File
@@ -24,7 +24,7 @@ Python-basiertes System zur Synchronisation eines Google Calendar ICS-Feeds nach
└─────────────────┘
```
**Entscheidung:** Gleicher Docker Build (ein Dockerfile), zwei verschiedene Container/Services via `docker-compose.yml`. Das Image wird mit einem `--entrypoint` Parameter gesteuert.
**Entscheidung:** Gleicher Docker Build (ein Dockerfile), zwei verschiedene Container/Services via `docker-compose.yml`. Der jeweilige Service wird via `command` Parameter gesteuert (`python sync.py` vs. `uvicorn api.main:app`).
## 3. Bestehendes System (Sync Tool)
@@ -89,12 +89,14 @@ Gibt die nächsten N Termine zurück.
"location": "Raum 101",
"start_at": "2025-01-15T10:00:00",
"end_at": "2025-01-15T11:00:00",
"all_day": false,
"status": "CONFIRMED"
}
],
"all_day": false,
"status": "CONFIRMED",
"timezone": "Europe/Berlin"
}
],
"count": 10,
"query_time": "2025-01-14T14:30:00Z"
"query_time": "2025-01-14T14:30:00Z",
"timezone": "Europe/Berlin"
}
```
@@ -123,12 +125,12 @@ Healthcheck Endpoint für den API Container.
### 4.3 Technologie-Stack (API)
- **Framework:** FastAPI
- **Templating:** Jinja2 (server-side rendering)
- **DB-Zugriff:** mysql-connector-python (gleicher Connection-Pool wie Sync)
- **DB-Zugriff:** mysql-connector-python (shared `get_connection()` aus `api/database.py`)
- **Port:** 8000 (konfigurierbar via `API_PORT`)
### 4.4 Additional Environment Variablen (API)
- `API_PORT` - Port für den API Server (default: 8000)
- `API_HOST` - Bind Address (default: 0.0.0.0)
- `TIMEZONE` - Zeitzone für die Anzeige von Zeiten (default: UTC)
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` - Identisch zum Sync
## 5. Docker Setup
@@ -174,6 +176,9 @@ services:
- WINDOW_FUTURE_DAYS=${WINDOW_FUTURE_DAYS:-365}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- HEALTHCHECK_URL=${HEALTHCHECK_URL:-}
- DB_BOOTSTRAP=${DB_BOOTSTRAP:-false}
- DB_ROOT_USER=${DB_ROOT_USER:-}
- DB_ROOT_PASSWORD=${DB_ROOT_PASSWORD:-}
networks:
- docker-backend
@@ -191,6 +196,7 @@ services:
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- TIMEZONE=${TIMEZONE:-UTC}
labels:
- "com.centurylinklabs.watchtower.enable=true"
networks:
@@ -218,6 +224,8 @@ networks:
├── mariadb-setup.sql # Manuelles DB-Setup Script
├── .env.example # Beispiel-Umgebungsvariablen (erweitert)
├── SPEC.md # Diese Spezifikation
├── PLAN.md # Implementierungsplan
├── AGENTS.md # Richtlinien für AI-Agenten
└── .github/workflows/ # CI/CD (Docker Build + Push)
```
@@ -269,4 +277,4 @@ Bestehender GitHub Actions Workflow erweitern:
## 11. Future Enhancements (nicht im Scope)
- [ ] Kalender-Filter UI (nach calendar_label)
- [ ] Suchfunktion nach Event-Titel
- [x] Suchfunktion nach Event-Titel (implementiert)
+54 -21
View File
@@ -1,8 +1,10 @@
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
from fastapi import FastAPI, HTTPException, Query
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
@@ -17,6 +19,29 @@ templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
SELECT_COLUMNS = "id, summary, description, location, start_at, end_at, all_day, status"
# Get timezone from environment, default to UTC
TIMEZONE_NAME = os.environ.get("TIMEZONE", "UTC")
try:
TIMEZONE = ZoneInfo(TIMEZONE_NAME)
except Exception:
log.warning(f"Invalid TIMEZONE '{TIMEZONE_NAME}', falling back to UTC")
TIMEZONE = ZoneInfo("UTC")
def to_local_timezone(dt: datetime) -> datetime:
"""Konvertiere naive UTC datetime zu Benutzer-Zeitzone."""
if dt is None:
return None
return dt.replace(tzinfo=timezone.utc).astimezone(TIMEZONE)
def to_iso_local(dt: datetime) -> str:
"""Konvertiere naive UTC datetime zu ISO-String in lokaler Zeitzone."""
if dt is None:
return None
local_dt = to_local_timezone(dt)
return local_dt.isoformat()
class EventResponse(BaseModel):
id: int
@@ -25,6 +50,7 @@ class EventResponse(BaseModel):
location: str | None
start_at: str
end_at: str | None
timezone: str
all_day: bool
status: str
@@ -33,6 +59,7 @@ class EventsListResponse(BaseModel):
events: list[EventResponse]
count: int
query_time: str
timezone: str
def row_to_event(row) -> EventResponse:
@@ -41,32 +68,34 @@ def row_to_event(row) -> EventResponse:
summary=row[1],
description=row[2],
location=row[3],
start_at=row[4].isoformat() if row[4] else None,
end_at=row[5].isoformat() if row[5] else None,
start_at=to_iso_local(row[4]) if row[4] else None,
end_at=to_iso_local(row[5]) if row[5] else None,
timezone=TIMEZONE_NAME,
all_day=bool(row[6]),
status=row[7],
)
def fetch_events(limit: int = 10, search: str | None = None) -> list[dict]:
def fetch_events(limit: int = 10, search: str | None = None, calendar_label: str | None = None) -> list[dict]:
conn = get_connection()
try:
cur = conn.cursor()
try:
conditions = ["deleted = 0", "start_at >= NOW()"]
params: list = []
if search:
cur.execute(
f"SELECT {SELECT_COLUMNS} FROM calendar_events "
"WHERE deleted = 0 AND start_at >= NOW() AND summary LIKE %s "
"ORDER BY start_at ASC LIMIT %s",
(f"%{search}%", limit),
)
else:
cur.execute(
f"SELECT {SELECT_COLUMNS} FROM calendar_events "
"WHERE deleted = 0 AND start_at >= NOW() "
"ORDER BY start_at ASC LIMIT %s",
(limit,),
)
conditions.append("summary LIKE %s")
params.append(f"%{search}%")
if calendar_label:
conditions.append("calendar_label = %s")
params.append(calendar_label)
params.append(limit)
where = " WHERE " + " AND ".join(conditions)
cur.execute(
f"SELECT {SELECT_COLUMNS} FROM calendar_events "
f"{where} ORDER BY start_at ASC LIMIT %s",
tuple(params),
)
return cur.fetchall()
finally:
cur.close()
@@ -83,9 +112,10 @@ def health():
def get_events(
limit: int = Query(default=10, ge=1, le=50),
search: str | None = Query(default=None),
calendar_label: str | None = Query(default=None),
):
try:
rows = fetch_events(limit=limit, search=search)
rows = fetch_events(limit=limit, search=search, calendar_label=calendar_label)
except Exception:
log.exception("DB-Fehler bei /api/events")
raise HTTPException(status_code=500, detail="Database error")
@@ -96,6 +126,7 @@ def get_events(
events=events,
count=len(events),
query_time=datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + "Z",
timezone=TIMEZONE_NAME, # <--- Add this line
)
@@ -127,6 +158,7 @@ def get_event(event_id: int):
@app.get("/", response_class=HTMLResponse)
def index(
request: Request,
search: str | None = Query(default=None),
limit: int = Query(default=10, ge=1, le=50),
):
@@ -143,13 +175,14 @@ def index(
"summary": row[1],
"description": row[2],
"location": row[3],
"start_at": row[4],
"end_at": row[5],
"start_at": to_iso_local(row[4]) if row[4] else None,
"end_at": to_iso_local(row[5]) if row[5] else None,
"timezone": TIMEZONE_NAME,
"all_day": bool(row[6]),
"status": row[7],
})
return templates.TemplateResponse(
"index.html",
{"request": {}, "events": events, "search": search or ""},
{"request": request, "events": events, "search": search or ""},
)
+4 -4
View File
@@ -116,7 +116,7 @@
}
.event-location::before {
content: "\1F4CD ";
content: "\\1F4CD ";
}
.badge {
@@ -181,11 +181,11 @@
<div>
<div class="event-date">
{% if event.all_day %}
{{ event.start_at.strftime('%d.%m.%Y') }} <span class="all-day">Ganztägig</span>
{{ event.start_at.split('T')[0] }} <span class="all-day">Ganztägig</span>
{% elif event.end_at %}
{{ event.start_at.strftime('%d.%m.%Y %H:%M') }} - {{ event.end_at.strftime('%H:%M') }}
{{ event.start_at.split('T')[0] }} {{ event.start_at.split('T')[1][:5] }} - {{ event.end_at.split('T')[1][:5] }} {{ event.timezone }}
{% else %}
{{ event.start_at.strftime('%d.%m.%Y %H:%M') }}
{{ event.start_at.split('T')[0] }} {{ event.start_at.split('T')[1][:5] }} {{ event.timezone }}
{% endif %}
</div>
<div class="event-summary">{{ event.summary or '(Kein Titel)' }}</div>
+15 -1
View File
@@ -22,6 +22,19 @@ services:
- WINDOW_FUTURE_DAYS=${WINDOW_FUTURE_DAYS:-365}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- HEALTHCHECK_URL=${HEALTHCHECK_URL:-}
- DB_BOOTSTRAP=${DB_BOOTSTRAP:-false}
- DB_ROOT_USER=${DB_ROOT_USER:-}
- DB_ROOT_PASSWORD=${DB_ROOT_PASSWORD:-}
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
- SMTP_FROM=${SMTP_FROM:-}
- SMTP_USE_TLS=${SMTP_USE_TLS:-true}
- NOTIFY_EMAIL=${NOTIFY_EMAIL:-}
- NOTIFY_TIME=${NOTIFY_TIME:-6}
- NOTIFY_TIMEZONE=${NOTIFY_TIMEZONE:-Europe/Berlin}
networks:
- docker-backend
@@ -33,7 +46,7 @@ services:
command: ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
ports:
- "8000:8000"
- "${API_PORT:-8000}:8000"
environment:
- DB_HOST=${DB_HOST:-mariadb.fritz.box}
@@ -42,6 +55,7 @@ services:
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- TIMEZONE=${TIMEZONE:-Europe/Berlin} # <--- Add this line
labels:
- "com.centurylinklabs.watchtower.enable=true"
+177 -1
View File
@@ -10,7 +10,11 @@ import sys
import time
import logging
import hashlib
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
import requests
import icalendar
@@ -45,6 +49,16 @@ DB_BOOTSTRAP = os.environ.get("DB_BOOTSTRAP", "false").lower() == "true"
DB_ROOT_USER = os.environ.get("DB_ROOT_USER")
DB_ROOT_PASSWORD = os.environ.get("DB_ROOT_PASSWORD")
SMTP_HOST = os.environ.get("SMTP_HOST", "")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
SMTP_USER = os.environ.get("SMTP_USER", "")
SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "")
SMTP_FROM = os.environ.get("SMTP_FROM", "")
SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "true").lower() == "true"
NOTIFY_EMAIL = os.environ.get("NOTIFY_EMAIL", "")
NOTIFY_TIME = int(os.environ.get("NOTIFY_TIME", "6"))
NOTIFY_TIMEZONE = os.environ.get("NOTIFY_TIMEZONE", "Europe/Berlin")
def bootstrap_database():
"""Legt DB_NAME und DB_USER an, falls sie noch nicht existieren.
@@ -106,6 +120,15 @@ def ensure_schema(conn):
INDEX idx_deleted (deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS daily_notification_log (
id INT AUTO_INCREMENT PRIMARY KEY,
notify_date DATE NOT NULL,
sent_at DATETIME NOT NULL,
event_count INT NOT NULL,
UNIQUE KEY uk_date (notify_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""")
conn.commit()
cur.close()
@@ -198,6 +221,137 @@ def mark_missing_as_deleted(cur, calendar_label, run_ts, window_start, window_en
return cur.rowcount
def get_today_events(conn, calendar_label):
tz = ZoneInfo(NOTIFY_TIMEZONE)
now = datetime.now(tz)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + timedelta(days=1)
today_start_naive = today_start.replace(tzinfo=None)
today_end_naive = today_end.replace(tzinfo=None)
cur = conn.cursor(dictionary=True)
cur.execute(
"""
SELECT summary, start_at, end_at, location
FROM calendar_events
WHERE calendar_label = %s
AND deleted = 0
AND all_day = 0
AND start_at >= %s
AND start_at < %s
ORDER BY start_at ASC
""",
(calendar_label, today_start_naive, today_end_naive),
)
events = cur.fetchall()
cur.close()
return events
def should_notify(conn):
tz = ZoneInfo(NOTIFY_TIMEZONE)
now = datetime.now(tz)
if now.hour != NOTIFY_TIME:
return False
cur = conn.cursor()
cur.execute(
"SELECT COUNT(*) FROM daily_notification_log WHERE notify_date = CURDATE()"
)
count = cur.fetchone()[0]
cur.close()
return count == 0
def log_notification(conn, event_count):
cur = conn.cursor()
cur.execute(
"INSERT INTO daily_notification_log (notify_date, sent_at, event_count) VALUES (CURDATE(), NOW(), %s)",
(event_count,),
)
conn.commit()
cur.close()
def format_event_time(start_at, end_at):
tz = ZoneInfo(NOTIFY_TIMEZONE)
start_local = start_at.replace(tzinfo=timezone.utc).astimezone(tz)
start_str = start_local.strftime("%H:%M")
if end_at:
end_local = end_at.replace(tzinfo=timezone.utc).astimezone(tz)
end_str = end_local.strftime("%H:%M")
return f"{start_str} - {end_str}"
return start_str
def send_notification(events):
if not SMTP_HOST or not NOTIFY_EMAIL:
log.warning("SMTP-Konfiguration unvollstaendig, ueberspringe Benachrichtigung")
return
tz = ZoneInfo(NOTIFY_TIMEZONE)
now = datetime.now(tz)
date_str = now.strftime("%d.%m.%Y")
count = len(events)
if count == 1:
event = events[0]
time_str = format_event_time(event["start_at"], event.get("end_at"))
subject = f"Kalender heute: {time_str} - {event['summary']}"
else:
subject = f"Kalender heute: {count} Termine"
event_rows = ""
for event in events:
time_str = format_event_time(event["start_at"], event.get("end_at"))
summary = event["summary"]
location = f" ({event['location']})" if event.get("location") else ""
event_rows += f"""
<tr>
<td style="padding: 8px 12px; border-bottom: 1px solid #eee; font-weight: bold; white-space: nowrap;">{time_str}</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #eee;">{summary}{location}</td>
</tr>"""
html = f"""
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #333;">Guten Morgen!</h2>
<p style="color: #555;">Heute, <strong>{date_str}</strong>, stehen folgende Termine an:</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0; background: #f9f9f9; border-radius: 8px; overflow: hidden;">
{event_rows}
</table>
<p style="color: #888; font-size: 12px;">Viel Erfolg heute!</p>
</body>
</html>
"""
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = SMTP_FROM
msg["To"] = NOTIFY_EMAIL
msg.attach(MIMEText(html, "html", "utf-8"))
try:
if SMTP_USE_TLS:
server = smtplib.SMTP(SMTP_HOST, SMTP_PORT)
server.starttls()
else:
server = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT)
if SMTP_USER and SMTP_PASSWORD:
server.login(SMTP_USER, SMTP_PASSWORD)
server.sendmail(SMTP_FROM, [NOTIFY_EMAIL], msg.as_string())
server.quit()
log.info("Benachrichtigung gesendet: %s", subject)
except Exception:
log.exception("Fehler beim Senden der Benachrichtigung")
def ping_healthcheck():
if not HEALTHCHECK_URL:
return
@@ -238,15 +392,37 @@ def run_sync_once():
conn.close()
def check_and_send_notification():
if not SMTP_HOST or not NOTIFY_EMAIL:
return
conn = get_connection(autocommit=True)
try:
if should_notify(conn):
events = get_today_events(conn, CALENDAR_LABEL)
if events:
send_notification(events)
log_notification(conn, len(events))
log.info("Tagesbenachrichtigung fuer %d Events gesendet", len(events))
else:
log.info("Keine Termine heute, Benachrichtigung wird uebersprungen")
except Exception:
log.exception("Fehler bei der Tagesbenachrichtigung")
finally:
conn.close()
def main():
bootstrap_database()
log.info(
"calendar-sync gestartet | Intervall=%smin | Fenster=-%dd/+%dd",
"calendar-sync gestartet | Intervall=%smin | Fenster=-%dd/+%dd | Benachrichtigung um %s:00 %s",
SYNC_INTERVAL_MINUTES, WINDOW_PAST_DAYS, WINDOW_FUTURE_DAYS,
NOTIFY_TIME, NOTIFY_TIMEZONE,
)
while True:
try:
run_sync_once()
check_and_send_notification()
except Exception:
log.exception("Sync-Durchlauf fehlgeschlagen, versuche es beim naechsten Intervall erneut")
time.sleep(SYNC_INTERVAL_MINUTES * 60)