mirror of
https://github.com/skoelle/calender_sync.git
synced 2026-09-18 10:40:25 +00:00
Compare commits
5
Commits
main
...
2fbfe31ffb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fbfe31ffb | ||
|
|
1b5e09a342 | ||
|
|
25a38f886d | ||
|
|
a84e27844b | ||
|
|
d925ac1253 |
@@ -16,3 +16,25 @@ LOG_LEVEL=INFO
|
|||||||
|
|
||||||
# Optional: Healthchecks.io / Uptime Kuma URL (wird nach jedem Sync gepingt)
|
# Optional: Healthchecks.io / Uptime Kuma URL (wird nach jedem Sync gepingt)
|
||||||
# HEALTHCHECK_URL=https://hc-ping.com/DEINE_UUID
|
# 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
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ Die API (`api/main.py`) ist eine separarte FastAPI-App die als eigenständiger C
|
|||||||
|
|
||||||
### Datenbank-Schema
|
### Datenbank-Schema
|
||||||
Schema wird in `ensure_schema()` per `CREATE TABLE IF NOT EXISTS` erstellt. Bei Schema-Änderungen:
|
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)
|
- MariaDB-kompatibles SQL verwenden (kein PostgreSQL-Specific)
|
||||||
- Indexe für Performance bedenken
|
- 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.
|
Verwendet `recurring_ical_events` Bibliothek für RRULE/EXDATE/RECURRENCE-ID Expansion. Fenster wird über `WINDOW_PAST_DAYS`/`WINDOW_FUTURE_DAYS` gesteuert.
|
||||||
|
|
||||||
### UTC-Normalisierung
|
### 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
|
### Soft-Delete
|
||||||
Events werden nicht gelöscht, sondern mit `deleted=1` markiert (`mark_missing_as_deleted()`).
|
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
|
## Entwicklung
|
||||||
|
|
||||||
### Lokaler Test
|
### Lokaler Test
|
||||||
|
|||||||
@@ -128,6 +128,25 @@ jinja2==3.1.4
|
|||||||
|
|
||||||
## Offene Punkte
|
## 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] DB Bootstrap - bleibt in sync.py
|
||||||
- [x] Template Styling - einfaches CSS, kein Framework
|
- [x] Template Styling - einfaches CSS, kein Framework
|
||||||
- [x] Search - optionaler Suchbegriff auf Event-Titel (API + Frontend)
|
- [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
|
||||||
|
|||||||
@@ -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)
|
- Zeitfenster-konfiguration für Vergangenheit/Future (standardmäßig -90 Tage / +365 Tage)
|
||||||
- Web-UI zur Anzeige anstehender Termine mit Suchfunktion
|
- Web-UI zur Anzeige anstehender Termine mit Suchfunktion
|
||||||
- REST API für programmsprachigen Zugriff auf Kalenderdaten
|
- REST API für programmsprachigen Zugriff auf Kalenderdaten
|
||||||
|
- Optionale tägliche E-Mail-Benachrichtigung um konfigurierte Uhrzeit
|
||||||
|
|
||||||
## Voraussetzungen
|
## 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_BOOTSTRAP` | `false` | DB + User beim Start erstellen |
|
||||||
| `DB_ROOT_USER` | - | Root-User fürs Bootstrap |
|
| `DB_ROOT_USER` | - | Root-User fürs Bootstrap |
|
||||||
| `DB_ROOT_PASSWORD` | - | Root-Passwort 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
|
## Datenbank-Schema
|
||||||
|
|
||||||
@@ -94,6 +119,15 @@ Tabelle `calendar_events`:
|
|||||||
| `created_at` | DATETIME | Erstellungszeitpunkt |
|
| `created_at` | DATETIME | Erstellungszeitpunkt |
|
||||||
| `updated_at` | DATETIME | Letzte Änderung |
|
| `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
|
## 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.
|
Das Projekt enthält eine FastAPI-basierte Webanwendung die als separater Container (`calendar-api`) läuft und auf Port `8000` erreichbar ist.
|
||||||
@@ -133,11 +167,13 @@ curl http://localhost:8000/api/events/42
|
|||||||
"start_at": "2025-01-15T10:00:00",
|
"start_at": "2025-01-15T10:00:00",
|
||||||
"end_at": "2025-01-15T11:00:00",
|
"end_at": "2025-01-15T11:00:00",
|
||||||
"all_day": false,
|
"all_day": false,
|
||||||
"status": "CONFIRMED"
|
"status": "CONFIRMED",
|
||||||
|
"timezone": "Europe/Berlin"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"query_time": "2025-01-15T09:30:00Z"
|
"query_time": "2025-01-15T09:30:00Z",
|
||||||
|
"timezone": "Europe/Berlin"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
## 3. Bestehendes System (Sync Tool)
|
||||||
|
|
||||||
@@ -90,11 +90,13 @@ Gibt die nächsten N Termine zurück.
|
|||||||
"start_at": "2025-01-15T10:00:00",
|
"start_at": "2025-01-15T10:00:00",
|
||||||
"end_at": "2025-01-15T11:00:00",
|
"end_at": "2025-01-15T11:00:00",
|
||||||
"all_day": false,
|
"all_day": false,
|
||||||
"status": "CONFIRMED"
|
"status": "CONFIRMED",
|
||||||
|
"timezone": "Europe/Berlin"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"count": 10,
|
"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)
|
### 4.3 Technologie-Stack (API)
|
||||||
- **Framework:** FastAPI
|
- **Framework:** FastAPI
|
||||||
- **Templating:** Jinja2 (server-side rendering)
|
- **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`)
|
- **Port:** 8000 (konfigurierbar via `API_PORT`)
|
||||||
|
|
||||||
### 4.4 Additional Environment Variablen (API)
|
### 4.4 Additional Environment Variablen (API)
|
||||||
- `API_PORT` - Port für den API Server (default: 8000)
|
- `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
|
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` - Identisch zum Sync
|
||||||
|
|
||||||
## 5. Docker Setup
|
## 5. Docker Setup
|
||||||
@@ -174,6 +176,9 @@ services:
|
|||||||
- WINDOW_FUTURE_DAYS=${WINDOW_FUTURE_DAYS:-365}
|
- WINDOW_FUTURE_DAYS=${WINDOW_FUTURE_DAYS:-365}
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
- HEALTHCHECK_URL=${HEALTHCHECK_URL:-}
|
- HEALTHCHECK_URL=${HEALTHCHECK_URL:-}
|
||||||
|
- DB_BOOTSTRAP=${DB_BOOTSTRAP:-false}
|
||||||
|
- DB_ROOT_USER=${DB_ROOT_USER:-}
|
||||||
|
- DB_ROOT_PASSWORD=${DB_ROOT_PASSWORD:-}
|
||||||
networks:
|
networks:
|
||||||
- docker-backend
|
- docker-backend
|
||||||
|
|
||||||
@@ -191,6 +196,7 @@ services:
|
|||||||
- DB_USER=${DB_USER}
|
- DB_USER=${DB_USER}
|
||||||
- DB_PASSWORD=${DB_PASSWORD}
|
- DB_PASSWORD=${DB_PASSWORD}
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
|
- TIMEZONE=${TIMEZONE:-UTC}
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
networks:
|
networks:
|
||||||
@@ -218,6 +224,8 @@ networks:
|
|||||||
├── mariadb-setup.sql # Manuelles DB-Setup Script
|
├── mariadb-setup.sql # Manuelles DB-Setup Script
|
||||||
├── .env.example # Beispiel-Umgebungsvariablen (erweitert)
|
├── .env.example # Beispiel-Umgebungsvariablen (erweitert)
|
||||||
├── SPEC.md # Diese Spezifikation
|
├── SPEC.md # Diese Spezifikation
|
||||||
|
├── PLAN.md # Implementierungsplan
|
||||||
|
├── AGENTS.md # Richtlinien für AI-Agenten
|
||||||
└── .github/workflows/ # CI/CD (Docker Build + Push)
|
└── .github/workflows/ # CI/CD (Docker Build + Push)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -269,4 +277,4 @@ Bestehender GitHub Actions Workflow erweitern:
|
|||||||
## 11. Future Enhancements (nicht im Scope)
|
## 11. Future Enhancements (nicht im Scope)
|
||||||
|
|
||||||
- [ ] Kalender-Filter UI (nach calendar_label)
|
- [ ] Kalender-Filter UI (nach calendar_label)
|
||||||
- [ ] Suchfunktion nach Event-Titel
|
- [x] Suchfunktion nach Event-Titel (implementiert)
|
||||||
|
|||||||
+51
-18
@@ -1,8 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
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.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from pydantic import BaseModel
|
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"
|
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):
|
class EventResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
@@ -25,6 +50,7 @@ class EventResponse(BaseModel):
|
|||||||
location: str | None
|
location: str | None
|
||||||
start_at: str
|
start_at: str
|
||||||
end_at: str | None
|
end_at: str | None
|
||||||
|
timezone: str
|
||||||
all_day: bool
|
all_day: bool
|
||||||
status: str
|
status: str
|
||||||
|
|
||||||
@@ -33,6 +59,7 @@ class EventsListResponse(BaseModel):
|
|||||||
events: list[EventResponse]
|
events: list[EventResponse]
|
||||||
count: int
|
count: int
|
||||||
query_time: str
|
query_time: str
|
||||||
|
timezone: str
|
||||||
|
|
||||||
|
|
||||||
def row_to_event(row) -> EventResponse:
|
def row_to_event(row) -> EventResponse:
|
||||||
@@ -41,31 +68,33 @@ def row_to_event(row) -> EventResponse:
|
|||||||
summary=row[1],
|
summary=row[1],
|
||||||
description=row[2],
|
description=row[2],
|
||||||
location=row[3],
|
location=row[3],
|
||||||
start_at=row[4].isoformat() if row[4] else None,
|
start_at=to_iso_local(row[4]) if row[4] else None,
|
||||||
end_at=row[5].isoformat() if row[5] else None,
|
end_at=to_iso_local(row[5]) if row[5] else None,
|
||||||
|
timezone=TIMEZONE_NAME,
|
||||||
all_day=bool(row[6]),
|
all_day=bool(row[6]),
|
||||||
status=row[7],
|
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()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
try:
|
try:
|
||||||
|
conditions = ["deleted = 0", "start_at >= NOW()"]
|
||||||
|
params: list = []
|
||||||
if search:
|
if search:
|
||||||
|
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(
|
cur.execute(
|
||||||
f"SELECT {SELECT_COLUMNS} FROM calendar_events "
|
f"SELECT {SELECT_COLUMNS} FROM calendar_events "
|
||||||
"WHERE deleted = 0 AND start_at >= NOW() AND summary LIKE %s "
|
f"{where} ORDER BY start_at ASC LIMIT %s",
|
||||||
"ORDER BY start_at ASC LIMIT %s",
|
tuple(params),
|
||||||
(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,),
|
|
||||||
)
|
)
|
||||||
return cur.fetchall()
|
return cur.fetchall()
|
||||||
finally:
|
finally:
|
||||||
@@ -83,9 +112,10 @@ def health():
|
|||||||
def get_events(
|
def get_events(
|
||||||
limit: int = Query(default=10, ge=1, le=50),
|
limit: int = Query(default=10, ge=1, le=50),
|
||||||
search: str | None = Query(default=None),
|
search: str | None = Query(default=None),
|
||||||
|
calendar_label: str | None = Query(default=None),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
rows = fetch_events(limit=limit, search=search)
|
rows = fetch_events(limit=limit, search=search, calendar_label=calendar_label)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("DB-Fehler bei /api/events")
|
log.exception("DB-Fehler bei /api/events")
|
||||||
raise HTTPException(status_code=500, detail="Database error")
|
raise HTTPException(status_code=500, detail="Database error")
|
||||||
@@ -96,6 +126,7 @@ def get_events(
|
|||||||
events=events,
|
events=events,
|
||||||
count=len(events),
|
count=len(events),
|
||||||
query_time=datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + "Z",
|
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)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index(
|
def index(
|
||||||
|
request: Request,
|
||||||
search: str | None = Query(default=None),
|
search: str | None = Query(default=None),
|
||||||
limit: int = Query(default=10, ge=1, le=50),
|
limit: int = Query(default=10, ge=1, le=50),
|
||||||
):
|
):
|
||||||
@@ -143,13 +175,14 @@ def index(
|
|||||||
"summary": row[1],
|
"summary": row[1],
|
||||||
"description": row[2],
|
"description": row[2],
|
||||||
"location": row[3],
|
"location": row[3],
|
||||||
"start_at": row[4],
|
"start_at": to_iso_local(row[4]) if row[4] else None,
|
||||||
"end_at": row[5],
|
"end_at": to_iso_local(row[5]) if row[5] else None,
|
||||||
|
"timezone": TIMEZONE_NAME,
|
||||||
"all_day": bool(row[6]),
|
"all_day": bool(row[6]),
|
||||||
"status": row[7],
|
"status": row[7],
|
||||||
})
|
})
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"index.html",
|
"index.html",
|
||||||
{"request": {}, "events": events, "search": search or ""},
|
{"request": request, "events": events, "search": search or ""},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -116,7 +116,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.event-location::before {
|
.event-location::before {
|
||||||
content: "\1F4CD ";
|
content: "\\1F4CD ";
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
@@ -181,11 +181,11 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="event-date">
|
<div class="event-date">
|
||||||
{% if event.all_day %}
|
{% 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 %}
|
{% 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="event-summary">{{ event.summary or '(Kein Titel)' }}</div>
|
<div class="event-summary">{{ event.summary or '(Kein Titel)' }}</div>
|
||||||
|
|||||||
+15
-1
@@ -22,6 +22,19 @@ services:
|
|||||||
- WINDOW_FUTURE_DAYS=${WINDOW_FUTURE_DAYS:-365}
|
- WINDOW_FUTURE_DAYS=${WINDOW_FUTURE_DAYS:-365}
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
- HEALTHCHECK_URL=${HEALTHCHECK_URL:-}
|
- 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:
|
networks:
|
||||||
- docker-backend
|
- docker-backend
|
||||||
@@ -33,7 +46,7 @@ services:
|
|||||||
command: ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
command: ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "${API_PORT:-8000}:8000"
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
- DB_HOST=${DB_HOST:-mariadb.fritz.box}
|
- DB_HOST=${DB_HOST:-mariadb.fritz.box}
|
||||||
@@ -42,6 +55,7 @@ services:
|
|||||||
- DB_USER=${DB_USER}
|
- DB_USER=${DB_USER}
|
||||||
- DB_PASSWORD=${DB_PASSWORD}
|
- DB_PASSWORD=${DB_PASSWORD}
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
|
- TIMEZONE=${TIMEZONE:-Europe/Berlin} # <--- Add this line
|
||||||
|
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import icalendar
|
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_USER = os.environ.get("DB_ROOT_USER")
|
||||||
DB_ROOT_PASSWORD = os.environ.get("DB_ROOT_PASSWORD")
|
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():
|
def bootstrap_database():
|
||||||
"""Legt DB_NAME und DB_USER an, falls sie noch nicht existieren.
|
"""Legt DB_NAME und DB_USER an, falls sie noch nicht existieren.
|
||||||
@@ -106,6 +120,15 @@ def ensure_schema(conn):
|
|||||||
INDEX idx_deleted (deleted)
|
INDEX idx_deleted (deleted)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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()
|
conn.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|
||||||
@@ -198,6 +221,137 @@ def mark_missing_as_deleted(cur, calendar_label, run_ts, window_start, window_en
|
|||||||
return cur.rowcount
|
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():
|
def ping_healthcheck():
|
||||||
if not HEALTHCHECK_URL:
|
if not HEALTHCHECK_URL:
|
||||||
return
|
return
|
||||||
@@ -238,15 +392,37 @@ def run_sync_once():
|
|||||||
conn.close()
|
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():
|
def main():
|
||||||
bootstrap_database()
|
bootstrap_database()
|
||||||
log.info(
|
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,
|
SYNC_INTERVAL_MINUTES, WINDOW_PAST_DAYS, WINDOW_FUTURE_DAYS,
|
||||||
|
NOTIFY_TIME, NOTIFY_TIMEZONE,
|
||||||
)
|
)
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
run_sync_once()
|
run_sync_once()
|
||||||
|
check_and_send_notification()
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("Sync-Durchlauf fehlgeschlagen, versuche es beim naechsten Intervall erneut")
|
log.exception("Sync-Durchlauf fehlgeschlagen, versuche es beim naechsten Intervall erneut")
|
||||||
time.sleep(SYNC_INTERVAL_MINUTES * 60)
|
time.sleep(SYNC_INTERVAL_MINUTES * 60)
|
||||||
|
|||||||
Reference in New Issue
Block a user