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
20 changed files with 92 additions and 673 deletions
-9
View File
@@ -38,12 +38,3 @@ API_PORT=8000
# NOTIFY_EMAIL=empfaenger@example.com # NOTIFY_EMAIL=empfaenger@example.com
# NOTIFY_TIME=6 # NOTIFY_TIME=6
# NOTIFY_TIMEZONE=Europe/Berlin # NOTIFY_TIMEZONE=Europe/Berlin
# Optional: Wöchentliche Vorab-Info (jeden Freitag)
# WEEKLY_NOTIFY_ENABLED=true
# WEEKLY_NOTIFY_DAY=5
# WEEKLY_NOTIFY_TIME=16
# WEEKLY_NOTIFY_TIMEZONE=Europe/Berlin
# WEEKLY_NOTIFY_EMAIL=empfaenger@example.com
# WEEKLY_SEARCHWORDS=Termin1,Termin2
# WEEKLY_BLACKLISTWORDS=Ausgeschlossen1,Ausgeschlossen2
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v6
- name: Log in to GHCR - name: Log in to GHCR
uses: docker/login-action@v4 uses: docker/login-action@v4
-2
View File
@@ -1,5 +1,3 @@
.env .env
__pycache__/ __pycache__/
*.pyc *.pyc
.venv/
demo/demo.db
-6
View File
@@ -1,6 +0,0 @@
title: "Google Calender Sync"
emoji: "📅"
category: code
subcategory: "Smart Home Apps"
status: active
stack: [Python, FastAPI, Uvicorn, Jinja2]
+3 -38
View File
@@ -9,7 +9,7 @@ Python-basierter Google Calendar → MariaDB Synchronizer. Läuft als Docker Con
## Technologie-Stack ## Technologie-Stack
- **Sprache**: Python 3.12 - **Sprache**: Python 3.12
- **Datenbank**: MariaDB (mysql-connector-python) oder SQLite (stdlib) via `DB_BACKEND` Env-Var - **Datenbank**: MariaDB (mysql-connector-python)
- **API**: FastAPI + Uvicorn + Jinja2 - **API**: FastAPI + Uvicorn + Jinja2
- **Docker**: Multi-Stage Build nicht verwendet, simples Slim-Image - **Docker**: Multi-Stage Build nicht verwendet, simples Slim-Image
- **Dependencies**: requests, icalendar, recurring-ical-events, mysql-connector-python, fastapi, uvicorn, jinja2 - **Dependencies**: requests, icalendar, recurring-ical-events, mysql-connector-python, fastapi, uvicorn, jinja2
@@ -21,12 +21,9 @@ Python-basierter Google Calendar → MariaDB Synchronizer. Läuft als Docker Con
├── sync.py # Hauptskript (alles in einer Datei) ├── sync.py # Hauptskript (alles in einer Datei)
├── api/ ├── api/
│ ├── main.py # FastAPI App (REST API + HTML UI) │ ├── main.py # FastAPI App (REST API + HTML UI)
│ ├── database.py # DB-Verbindung (MySQL + SQLite) │ ├── database.py # DB-Verbindung für die API
│ └── templates/ │ └── templates/
│ └── index.html # Jinja2 Template für Web-UI │ └── index.html # Jinja2 Template für Web-UI
├── demo/
│ └── seed.py # SQLite Demo-Datenbank erstellen
├── demo.sh # Lokaler Demo-Start (ohne Docker)
├── requirements.txt # Python Dependencies ├── requirements.txt # Python Dependencies
├── Dockerfile # Docker Image Definition ├── Dockerfile # Docker Image Definition
├── docker-compose.yml # Docker Compose Konfiguration ├── docker-compose.yml # Docker Compose Konfiguration
@@ -72,21 +69,9 @@ Sendet täglich um konfigurierte Uhrzeit eine HTML-Email mit anstehenden Termine
- Konfiguration über SMTP_* und NOTIFY_* Umgebungsvariablen - Konfiguration über SMTP_* und NOTIFY_* Umgebungsvariablen
- Funktionen: `get_today_events()`, `should_notify()`, `send_notification()`, `log_notification()` - Funktionen: `get_today_events()`, `should_notify()`, `send_notification()`, `log_notification()`
### Wöchentliche E-Mail-Benachrichtigung (Vorab-Info)
Sendet wöchentlich (standardmäßig Freitags) eine HTML-Email mit Terminen, die auf Suchbegriffe passen:
- Sucht nach Begriffen in summary, description und location
- Optionale Blacklist: Begriffe die ausgeschlossen werden (z.B. `WEEKLY_BLACKLISTWORDS=Ausgeschlossen1,Ausgeschlossen2`)
- Zeitraum: Samstag bis Freitag der nächsten Woche
- Subject: Bei 1 Termin "Vorab-Info: Termin am Mo, DD.MM.", bei mehreren "Vorab-Info: X Termine nächste Woche"
- Tracking via `weekly_notification_log` Tabelle (verhindert Doppelversand)
- Nur Termine mit Uhrzeit (`all_day=0`), keine Ganztagstermine
- Keine Email wenn keine Treffer
- Konfiguration über WEEKLY_* Umgebungsvariablen
- Funktionen: `parse_weekly_searchwords()`, `parse_weekly_blacklistwords()`, `get_weekly_events()`, `should_send_weekly()`, `send_weekly_notification()`, `log_weekly_notification()`, `check_and_send_weekly_notification()`
## Entwicklung ## Entwicklung
### Lokaler Test (Docker) ### Lokaler Test
```bash ```bash
# Dependencies installieren # Dependencies installieren
pip install -r requirements.txt pip install -r requirements.txt
@@ -98,20 +83,6 @@ cp .env.example .env
python sync.py python sync.py
``` ```
### Lokaler Test (Demo, ohne MariaDB)
```bash
# Startet venv, erstellt SQLite Demo-DB mit 4 Events, startet API
./demo.sh
```
Oder manuell:
```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python demo/seed.py
DB_BACKEND=sqlite DB_PATH=demo/demo.db python -m uvicorn api.main:app --port 8000
```
### Linting & Type Checking ### Linting & Type Checking
Keine Linting/Type-Checking Tools konfiguriert. Bei Bedarf hinzufügen: Keine Linting/Type-Checking Tools konfiguriert. Bei Bedarf hinzufügen:
- `ruff` für Linting - `ruff` für Linting
@@ -143,9 +114,3 @@ GitHub Actions Workflow:
- Baut Docker Image bei Push zu `main` - Baut Docker Image bei Push zu `main`
- Published nach `ghcr.io/skoelle/calender_sync:latest` - Published nach `ghcr.io/skoelle/calender_sync:latest`
- Keine Tests im Workflow (derzeit) - Keine Tests im Workflow (derzeit)
## Lizenz
MIT License - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
- Vollständiger Text in `LICENSE`
- Lizenz-Header in allen Python-Dateien
-1
View File
@@ -5,7 +5,6 @@ WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY LICENSE .
COPY sync.py . COPY sync.py .
COPY api/ ./api/ COPY api/ ./api/
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+59 -123
View File
@@ -1,32 +1,29 @@
# 📅 calender_sync # calender_sync
> Google Calendar (ICS-Feed) → MariaDB Sync fürs Homelab. Google Calendar (ICS-Feed) → MariaDB Sync fürs Homelab.
Läuft als Docker Container, pollt periodisch einen privaten Google Calendar ICS-Feed, expandiert RRULE/EXDATE/RECURRENCE-ID und schreibt einzelne Instanzen in eine MariaDB-Datenbank. Läuft als Docker Container, pollt periodisch einen privaten Google Calendar ICS-Feed, expandiert RRULE/EXDATE/RECURRENCE-ID und schreibt einzelne Instanzen in eine MariaDB-Datenbank.
## Features ## Features
- 🔄 Automatische Synchronisation alle 15 Minuten (konfigurierbar) - Automatische Synchronisation alle 15 Minuten (konfigurierbar)
- 📆 Expandiert wiederkehrende Events (RRULE) und Serien-Exceptions (EXDATE, RECURRENCE-ID) - Expandiert wiederkehrende Events (RRULE) und Serien-Exceptions (EXDATE, RECURRENCE-ID)
- 🗑️ Soft-Delete: Entfernte Events werden als `deleted=1` markiert, nicht gelöscht - Soft-Delete: Entfernte Events werden als `deleted=1` markiert, nicht gelöscht
- 🛠️ Optionales Database-Bootstrap: Erstellt DB und User automatisch bei `DB_BOOTSTRAP=true` - Optionales Database-Bootstrap: Erstellt DB und User automatisch bei `DB_BOOTSTRAP=true`
- 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 - Optionale tägliche E-Mail-Benachrichtigung um konfigurierte Uhrzeit
- 📬 Optionale wöchentliche Vorab-Info (z.B. freitags) mit Termine nach Suchbegriffen
[![UI](docs/screenshot_thumbnail.png)](docs/screenshot.png) ## Voraussetzungen
## 📋 Voraussetzungen - Docker & Docker Compose
- MariaDB Instanz (z.B. als Proxmox LXC)
- Google Calendar mit privatem ICS-Feed
- 🐳 Docker & Docker Compose ## Quick Start
- 🗃️ MariaDB Instanz (z.B. als Proxmox LXC)
- 📱 Google Calendar mit privatem ICS-Feed
## 🚀 Quick Start 1. **MariaDB Setup**
1. **🔧 MariaDB Setup**
Entweder manuell ausführen: Entweder manuell ausführen:
```bash ```bash
@@ -40,7 +37,7 @@ Läuft als Docker Container, pollt periodisch einen privaten Google Calendar ICS
DB_ROOT_PASSWORD=dein_root_passwort DB_ROOT_PASSWORD=dein_root_passwort
``` ```
2. **📄 .env anlegen** 2. **.env anlegen**
```bash ```bash
cp .env.example .env cp .env.example .env
@@ -50,38 +47,13 @@ Läuft als Docker Container, pollt periodisch einen privaten Google Calendar ICS
- `ICS_URL`: Privater ICS-Link aus den Google Calendar Einstellungen - `ICS_URL`: Privater ICS-Link aus den Google Calendar Einstellungen
- `DB_PASSWORD`: Sicheres Passwort für den calendar_sync User - `DB_PASSWORD`: Sicheres Passwort für den calendar_sync User
3. **▶️ Starten** 3. **Starten**
```bash ```bash
docker compose up -d docker compose up -d
``` ```
## 🎮 Demo / Lokaler Test (ohne Docker) ## Konfiguration
Schneller Weg um Web-UI und API lokal zu testen, ohne MariaDB oder Docker:
```bash
./demo.sh
```
Das Script erstellt eine virtuelle Python-Umgebung, installiert Dependencies, legt eine SQLite Demo-Datenbank mit 4 Beispiel-Terminen an und startet die API auf Port 8000.
**🔗 Endpoints:**
- 🌐 Web-UI: http://localhost:8000/
- 📊 API JSON: http://localhost:8000/api/events
- ❤️ Health: http://localhost:8000/api/health
**🎯 Demo-Events (4 Stück):**
| Termin | Zeit | Status |
|--------|------|--------|
| 📅 Team Daily | heute 14:0015:00 | ✅ CONFIRMED |
| 🧘 Yoga Kurs | heute 18:3020:00 | ✅ CONFIRMED |
| ⚠️ Projekt-Deadline | morgen Ganztag | 🟡 TENTATIVE |
| 👨‍⚕️ Arzttermin | übermorgen 10:0011:30 | ✅ CONFIRMED |
Die Demo verwendet SQLite (`DB_BACKEND=sqlite`) statt MariaDB. Die Events werden relativ zum heutigen Datum erstellt.
## ⚙️ Konfiguration
| Variable | Default | Beschreibung | | Variable | Default | Beschreibung |
|----------|---------|--------------| |----------|---------|--------------|
@@ -102,7 +74,7 @@ Die Demo verwendet SQLite (`DB_BACKEND=sqlite`) statt MariaDB. Die Events werden
| `API_PORT` | `8000` | Port für den API/Web-UI Container | | `API_PORT` | `8000` | Port für den API/Web-UI Container |
| `TIMEZONE` | `UTC` | Zeitzone für API/Web-UI Anzeige (z.B. `Europe/Berlin`) | | `TIMEZONE` | `UTC` | Zeitzone für API/Web-UI Anzeige (z.B. `Europe/Berlin`) |
### 📧 Optionale E-Mail-Benachrichtigung ### Optionale E-Mail-Benachrichtigung
Sendet täglich eine HTML-Email mit den anstehenden Terminen. Wird aktiviert wenn `SMTP_HOST` und `NOTIFY_EMAIL` gesetzt sind. Sendet täglich eine HTML-Email mit den anstehenden Terminen. Wird aktiviert wenn `SMTP_HOST` und `NOTIFY_EMAIL` gesetzt sind.
@@ -118,94 +90,58 @@ Sendet täglich eine HTML-Email mit den anstehenden Terminen. Wird aktiviert wen
| `NOTIFY_TIME` | `6` | Uhrzeit für Benachrichtigung (Stunde, 0-23) | | `NOTIFY_TIME` | `6` | Uhrzeit für Benachrichtigung (Stunde, 0-23) |
| `NOTIFY_TIMEZONE` | `Europe/Berlin` | Zeitzone für die Benachrichtigung | | `NOTIFY_TIMEZONE` | `Europe/Berlin` | Zeitzone für die Benachrichtigung |
**📬 Subject-Logik:** **Subject-Logik:**
- 📌 1 Termin: `Kalender heute: 09:00 - Meeting mit Team` - 1 Termin: `Kalender heute: 09:00 - Meeting mit Team`
- 📌 2+ Termine: `Kalender heute: 3 Termine` - 2+ Termine: `Kalender heute: 3 Termine`
**⚠️ Hinweis:** Ganztagstermine werden nicht in der Benachrichtigung berücksichtigt. **Hinweis:** Ganztagstermine werden nicht in der Benachrichtigung berücksichtigt.
### 📬 Optionale wöchentliche Vorab-Info ## Datenbank-Schema
Sendet wöchentlich (standardmäßig freitags) eine HTML-Email mit Terminen, die auf konfigurierte Suchbegriffe passen. Zeitraum ist immer Samstag bis Freitag der nächsten Woche. Wird aktiviert wenn `WEEKLY_NOTIFY_ENABLED=true` und mindestens ein Suchbegriff gesetzt ist. Tabelle `calendar_events`:
| Variable | Default | Beschreibung |
|----------|---------|--------------|
| `WEEKLY_NOTIFY_ENABLED` | `false` | Feature aktivieren |
| `WEEKLY_NOTIFY_DAY` | `5` | Wochentag (0=Mo, 1=Di, ..., 5=Fr) |
| `WEEKLY_NOTIFY_TIME` | `16` | Uhrzeit für Benachrichtigung (Stunde, 0-23) |
| `WEEKLY_NOTIFY_TIMEZONE` | `Europe/Berlin` | Zeitzone für die Benachrichtigung |
| `WEEKLY_NOTIFY_EMAIL` | - | Empfänger (Fallback: `NOTIFY_EMAIL`) |
| `WEEKLY_SEARCHWORDS` | - | Komma-separierte Suchbegriffe |
**📬 Subject-Logik:**
- 📌 1 Termin: `Vorab-Info: Termin am Fr, 15.08.`
- 📌 2+ Termine: `Vorab-Info: 3 Termine naechste Woche`
**💡 Beispiel:**
```bash
WEEKLY_NOTIFY_ENABLED=true
WEEKLY_NOTIFY_DAY=5
WEEKLY_NOTIFY_TIME=16
WEEKLY_SEARCHWORDS=Fussball,Arzttermine
```
**⚠️ Hinweis:** Keine Email wenn keine Treffer für die Suchbegriffe im Zeitraum.
## 🗃️ Datenbank-Schema
**Tabelle `calendar_events`:**
| Spalte | Typ | Beschreibung | | Spalte | Typ | Beschreibung |
|--------|-----|--------------| |--------|-----|--------------|
| `id` | 🆔 BIGINT PK | Auto-Increment | | `id` | BIGINT PK | Auto-Increment |
| `calendar_label` | 🏷️ VARCHAR(64) | Kalender-Label | | `calendar_label` | VARCHAR(64) | Kalender-Label |
| `instance_key` | 🔑 VARCHAR(255) | SHA1-basierte eindeutige Instanz-ID | | `instance_key` | VARCHAR(255) | SHA1-basierte eindeutige Instanz-ID |
| `uid` | 📌 VARCHAR(255) | ICS UID | | `uid` | VARCHAR(255) | ICS UID |
| `recurrence_id` | 🔄 VARCHAR(64) | RECURRENCE-ID bei Serien-Exceptions | | `recurrence_id` | VARCHAR(64) | RECURRENCE-ID bei Serien-Exceptions |
| `summary` | 📝 VARCHAR(512) | Titel | | `summary` | VARCHAR(512) | Titel |
| `description` | 📄 TEXT | Beschreibung | | `description` | TEXT | Beschreibung |
| `location` | 📍 VARCHAR(512) | Ort | | `location` | VARCHAR(512) | Ort |
| `start_at` | 🚀 DATETIME | Startzeit (UTC) | | `start_at` | DATETIME | Startzeit (UTC) |
| `end_at` | 🏁 DATETIME | Endzeit (UTC) | | `end_at` | DATETIME | Endzeit (UTC) |
| `all_day` | ☀️ TINYINT(1) | Ganz-tagig Flag | | `all_day` | TINYINT(1) | Ganz-tagig Flag |
| `status` | 📊 VARCHAR(32) | Event-Status | | `status` | VARCHAR(32) | Event-Status |
| `deleted` | 🗑️ TINYINT(1) | Soft-Delete Flag | | `deleted` | TINYINT(1) | Soft-Delete Flag |
| `last_seen_at` | 👁️ DATETIME | Letzte Synchronisation | | `last_seen_at` | DATETIME | Letzte Synchronisation |
| `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):** Tabelle `daily_notification_log` (optional, für E-Mail-Benachrichtigung):
| Spalte | Typ | Beschreibung | | Spalte | Typ | Beschreibung |
|--------|-----|--------------| |--------|-----|--------------|
| `id` | 🆔 INT PK | Auto-Increment | | `id` | INT PK | Auto-Increment |
| `notify_date` | 📅 DATE | Datum der Benachrichtigung | | `notify_date` | DATE | Datum der Benachrichtigung |
| `sent_at` | DATETIME | Zeitpunkt des Versands | | `sent_at` | DATETIME | Zeitpunkt des Versands |
| `event_count` | 🔢 INT | Anzahl Termine in der Email | | `event_count` | INT | Anzahl Termine in der Email |
**Tabelle `weekly_notification_log` (optional, für wöchentliche Vorab-Info):** ## Web-UI & API
| 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. Das Projekt enthält eine FastAPI-basierte Webanwendung die als separater Container (`calendar-api`) läuft und auf Port `8000` erreichbar ist.
### 🔗 Endpoints ### Endpoints
| Endpoint | Beschreibung | | Endpoint | Beschreibung |
|----------|--------------| |----------|--------------|
| `GET /` | 🌐 HTML-Seite mit anstehenden Terminen und Suchfunktion | | `GET /` | HTML-Seite mit anstehenden Terminen und Suchfunktion |
| `GET /api/health` | ❤️ Health Check (gibt `{"status": "ok"}` zurück) | | `GET /api/health` | Health Check (gibt `{"status": "ok"}` zurück) |
| `GET /api/events?limit=10&search=...` | 📊 JSON-Liste zukünftiger Events (nicht gelöscht) | | `GET /api/events?limit=10&search=...` | JSON-Liste zukünftiger Events (nicht gelöscht) |
| `GET /api/events/{event_id}` | 🔍 Einzelnes Event als JSON | | `GET /api/events/{event_id}` | Einzelnes Event als JSON |
### 💡 API Beispiel ### API Beispiel
```bash ```bash
# Alle anstehenden Events (max. 10) # Alle anstehenden Events (max. 10)
@@ -218,7 +154,7 @@ curl "http://localhost:8000/api/events?search=Meeting&limit=5"
curl http://localhost:8000/api/events/42 curl http://localhost:8000/api/events/42
``` ```
### 📋 JSON Response Format ### JSON Response Format
```json ```json
{ {
@@ -241,7 +177,7 @@ curl http://localhost:8000/api/events/42
} }
``` ```
## 🔄 CI/CD ## CI/CD
GitHub Actions Workflow (`docker-publish.yml`) baut und published das Docker Image automatisch nach GitHub Container Registry: GitHub Actions Workflow (`docker-publish.yml`) baut und published das Docker Image automatisch nach GitHub Container Registry:
@@ -249,6 +185,6 @@ GitHub Actions Workflow (`docker-publish.yml`) baut und published das Docker Ima
ghcr.io/skoelle/calender_sync:latest ghcr.io/skoelle/calender_sync:latest
``` ```
## 📜 Lizenz ## Lizenz
Lizenziert unter der [MIT License](LICENSE) - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de) Keine Lizenz angegeben.
-2
View File
@@ -1,2 +0,0 @@
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
# Licensed under the MIT License. See LICENSE file in project root for details.
+7 -25
View File
@@ -1,32 +1,14 @@
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
# Licensed under the MIT License. See LICENSE file in project root for details.
import os import os
import sqlite3 import mysql.connector
DB_BACKEND = os.environ.get("DB_BACKEND", "mysql") DB_HOST = os.environ.get("DB_HOST", "mariadb.fritz.box")
DB_PORT = int(os.environ.get("DB_PORT", "3306"))
DB_NAME = os.environ.get("DB_NAME", "calendar_sync")
DB_USER = os.environ["DB_USER"]
DB_PASSWORD = os.environ["DB_PASSWORD"]
if DB_BACKEND == "sqlite":
DB_PATH = os.environ.get("DB_PATH", "demo.db")
def get_connection(autocommit: bool = True): def get_connection(autocommit: bool = True):
conn = sqlite3.connect(DB_PATH)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.row_factory = sqlite3.Row
if autocommit:
conn.isolation_level = None
return conn
else:
import mysql.connector
DB_HOST = os.environ.get("DB_HOST", "mariadb.fritz.box")
DB_PORT = int(os.environ.get("DB_PORT", "3306"))
DB_NAME = os.environ.get("DB_NAME", "calendar_sync")
DB_USER = os.environ["DB_USER"]
DB_PASSWORD = os.environ["DB_PASSWORD"]
def get_connection(autocommit: bool = True):
return mysql.connector.connect( return mysql.connector.connect(
host=DB_HOST, host=DB_HOST,
port=DB_PORT, port=DB_PORT,
+7 -16
View File
@@ -1,6 +1,3 @@
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
# Licensed under the MIT License. See LICENSE file in project root for details.
import logging import logging
import os import os
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -12,13 +9,10 @@ from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pydantic import BaseModel from pydantic import BaseModel
from api.database import get_connection, DB_BACKEND from api.database import get_connection
log = logging.getLogger("calendar-api") log = logging.getLogger("calendar-api")
PH = "?" if DB_BACKEND == "sqlite" else "%s"
NOW_SQL = "datetime('now')" if DB_BACKEND == "sqlite" else "NOW()"
app = FastAPI(title="Calendar Sync API") app = FastAPI(title="Calendar Sync API")
templates = Jinja2Templates(directory=Path(__file__).parent / "templates") templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
@@ -38,8 +32,6 @@ def to_local_timezone(dt: datetime) -> datetime:
"""Konvertiere naive UTC datetime zu Benutzer-Zeitzone.""" """Konvertiere naive UTC datetime zu Benutzer-Zeitzone."""
if dt is None: if dt is None:
return None return None
if isinstance(dt, str):
dt = datetime.fromisoformat(dt)
return dt.replace(tzinfo=timezone.utc).astimezone(TIMEZONE) return dt.replace(tzinfo=timezone.utc).astimezone(TIMEZONE)
@@ -89,19 +81,19 @@ def fetch_events(limit: int = 10, search: str | None = None, calendar_label: str
try: try:
cur = conn.cursor() cur = conn.cursor()
try: try:
conditions = [f"deleted = 0", f"start_at >= {NOW_SQL}"] conditions = ["deleted = 0", "start_at >= NOW()"]
params: list = [] params: list = []
if search: if search:
conditions.append(f"summary LIKE {PH}") conditions.append("summary LIKE %s")
params.append(f"%{search}%") params.append(f"%{search}%")
if calendar_label: if calendar_label:
conditions.append(f"calendar_label = {PH}") conditions.append("calendar_label = %s")
params.append(calendar_label) params.append(calendar_label)
params.append(limit) params.append(limit)
where = " WHERE " + " AND ".join(conditions) where = " WHERE " + " AND ".join(conditions)
cur.execute( cur.execute(
f"SELECT {SELECT_COLUMNS} FROM calendar_events " f"SELECT {SELECT_COLUMNS} FROM calendar_events "
f"{where} ORDER BY start_at ASC LIMIT {PH}", f"{where} ORDER BY start_at ASC LIMIT %s",
tuple(params), tuple(params),
) )
return cur.fetchall() return cur.fetchall()
@@ -146,7 +138,7 @@ def get_event(event_id: int):
try: try:
cur.execute( cur.execute(
f"SELECT {SELECT_COLUMNS} FROM calendar_events " f"SELECT {SELECT_COLUMNS} FROM calendar_events "
f"WHERE id = {PH} AND deleted = 0", "WHERE id = %s AND deleted = 0",
(event_id,), (event_id,),
) )
row = cur.fetchone() row = cur.fetchone()
@@ -191,7 +183,6 @@ def index(
}) })
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"index.html", "index.html",
{"events": events, "search": search or ""}, {"request": request, "events": events, "search": search or ""},
) )
+2 -2
View File
@@ -116,7 +116,7 @@
} }
.event-location::before { .event-location::before {
content: "\1F4CD "; content: "\\1F4CD ";
} }
.badge { .badge {
@@ -163,7 +163,7 @@
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<h1>📆️ Termine</h1> <h1>Termine</h1>
<form class="search-form" method="get" action="/"> <form class="search-form" method="get" action="/">
<input type="text" name="search" value="{{ search }}" placeholder="Suche nach Titel..."> <input type="text" name="search" value="{{ search }}" placeholder="Suche nach Titel...">
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
VENV_DIR="$SCRIPT_DIR/.venv"
DB_PATH="$SCRIPT_DIR/demo/demo.db"
echo "=== Calendar Sync Demo ==="
# Venv erstellen
if [ ! -d "$VENV_DIR" ]; then
echo "Erstelle virtuelles Umfeld..."
python3 -m venv "$VENV_DIR"
fi
source "$VENV_DIR/bin/activate"
# Dependencies installieren (ohne mysql-connector)
pip install -q --upgrade pip
pip install -q fastapi uvicorn[standard] jinja2 icalendar recurring-ical-events
# Demo-Datenbank erstellen
echo "Erstelle Demo-Datenbank mit 4 Terminen..."
python "$SCRIPT_DIR/demo/seed.py"
# API starten
echo ""
echo "Starte API auf http://localhost:8000"
echo " Web-UI: http://localhost:8000/"
echo " API: http://localhost:8000/api/events"
echo " Health: http://localhost:8000/api/health"
echo ""
echo "Druecke Ctrl+C zum Beenden."
echo ""
cd "$SCRIPT_DIR"
DB_BACKEND=sqlite DB_PATH="$DB_PATH" \
exec python -m uvicorn api.main:app --host 0.0.0.0 --port 8000
-117
View File
@@ -1,117 +0,0 @@
#!/usr/bin/env python3
"""Erstellt eine lokale SQLite Demo-Datenbank mit 4 Beispiel-Terminen."""
import os
import sqlite3
from datetime import datetime, timedelta
DB_PATH = os.environ.get("DB_PATH", os.path.join(os.path.dirname(__file__), "demo.db"))
SCHEMA = """
CREATE TABLE IF NOT EXISTS calendar_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
calendar_label TEXT NOT NULL,
instance_key TEXT NOT NULL,
uid TEXT NOT NULL,
recurrence_id TEXT,
summary TEXT,
description TEXT,
location TEXT,
start_at TEXT NOT NULL,
end_at TEXT,
all_day INTEGER NOT NULL DEFAULT 0,
status TEXT DEFAULT 'CONFIRMED',
deleted INTEGER NOT NULL DEFAULT 0,
last_seen_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (calendar_label, instance_key)
);
CREATE INDEX IF NOT EXISTS idx_start ON calendar_events(start_at);
CREATE INDEX IF NOT EXISTS idx_deleted ON calendar_events(deleted);
"""
def seed():
if os.path.exists(DB_PATH):
os.remove(DB_PATH)
conn = sqlite3.connect(DB_PATH)
conn.executescript(SCHEMA)
now = datetime.now().replace(second=0, microsecond=0, tzinfo=None)
today = now.replace(hour=0, minute=0, second=0)
# Wenn es nach 14 Uhr ist, alle Termine einen Tag verschieben
# damit sie in der Demo immer sichtbar sind
day_offset = 1 if now.hour >= 14 else 0
base = today + timedelta(days=day_offset)
events = [
{
"calendar_label": "demo",
"instance_key": f"team-daily-{base.strftime('%Y%m%d')}",
"uid": "team-daily-001@demo",
"summary": "Team Daily",
"description": "Tägliches Standup-Meeting mit dem gesamten Team.",
"location": "Besprechungsraum A / Zoom",
"start_at": (base + timedelta(hours=14)).strftime("%Y-%m-%d %H:%M:%S"),
"end_at": (base + timedelta(hours=15)).strftime("%Y-%m-%d %H:%M:%S"),
"all_day": 0,
"status": "CONFIRMED",
},
{
"calendar_label": "demo",
"instance_key": f"yoga-{base.strftime('%Y%m%d')}",
"uid": "yoga-002@demo",
"summary": "Yoga Kurs",
"description": "Wöchentlicher Yoga-Kurs in der Volkshochschule.",
"location": "Volkshochschule, Raum 204",
"start_at": (base + timedelta(hours=18, minutes=30)).strftime("%Y-%m-%d %H:%M:%S"),
"end_at": (base + timedelta(hours=20)).strftime("%Y-%m-%d %H:%M:%S"),
"all_day": 0,
"status": "CONFIRMED",
},
{
"calendar_label": "demo",
"instance_key": f"deadline-{(base + timedelta(days=1)).strftime('%Y%m%d')}",
"uid": "deadline-003@demo",
"summary": "Projekt-Deadline",
"description": "Abgabe des Projektberichts an die Geschäftsleitung.",
"location": "",
"start_at": (base + timedelta(days=1)).strftime("%Y-%m-%d 00:00:00"),
"end_at": (base + timedelta(days=1)).strftime("%Y-%m-%d 23:59:59"),
"all_day": 1,
"status": "TENTATIVE",
},
{
"calendar_label": "demo",
"instance_key": f"arzt-{(base + timedelta(days=2)).strftime('%Y%m%d')}",
"uid": "arzt-004@demo",
"summary": "Arzttermin",
"description": "Routineuntersuchung beim Hausarzt. Bitte Impfpass mitbringen.",
"location": "Dr. Müller, Gesundheitsstraße 12",
"start_at": (base + timedelta(days=2, hours=10)).strftime("%Y-%m-%d %H:%M:%S"),
"end_at": (base + timedelta(days=2, hours=11, minutes=30)).strftime("%Y-%m-%d %H:%M:%S"),
"all_day": 0,
"status": "CONFIRMED",
},
]
conn.executemany(
"""INSERT INTO calendar_events
(calendar_label, instance_key, uid, summary, description, location,
start_at, end_at, all_day, status, last_seen_at)
VALUES
(:calendar_label, :instance_key, :uid, :summary, :description, :location,
:start_at, :end_at, :all_day, :status, datetime('now'))""",
events,
)
conn.commit()
conn.close()
print(f"Demo-Datenbank erstellt: {DB_PATH}")
print(f"4 Demo-Events eingefuegt.")
if __name__ == "__main__":
seed()
-8
View File
@@ -36,14 +36,6 @@ services:
- NOTIFY_TIME=${NOTIFY_TIME:-6} - NOTIFY_TIME=${NOTIFY_TIME:-6}
- NOTIFY_TIMEZONE=${NOTIFY_TIMEZONE:-Europe/Berlin} - NOTIFY_TIMEZONE=${NOTIFY_TIMEZONE:-Europe/Berlin}
- WEEKLY_NOTIFY_ENABLED=${WEEKLY_NOTIFY_ENABLED:-false}
- WEEKLY_NOTIFY_DAY=${WEEKLY_NOTIFY_DAY:-5}
- WEEKLY_NOTIFY_TIME=${WEEKLY_NOTIFY_TIME:-16}
- WEEKLY_NOTIFY_TIMEZONE=${WEEKLY_NOTIFY_TIMEZONE:-Europe/Berlin}
- WEEKLY_NOTIFY_EMAIL=${WEEKLY_NOTIFY_EMAIL:-}
- WEEKLY_SEARCHWORDS=${WEEKLY_SEARCHWORDS:-}
- WEEKLY_BLACKLISTWORDS=${WEEKLY_BLACKLISTWORDS:-}
networks: networks:
- docker-backend - docker-backend
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

-35
View File
@@ -1,35 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch", "pin", "digest"],
"automerge": true
},
{
"matchManagers": ["github-actions"],
"groupName": "GitHub Actions",
"automerge": true
},
{
"matchManagers": ["dockerfile", "docker-compose"],
"groupName": "Docker",
"automerge": false
},
{
"matchPackageNames": ["icalendar"],
"matchUpdateTypes": ["major"],
"automerge": false,
"labels": ["major-update"]
},
{
"matchPackageNames": ["mysql-connector-python"],
"matchUpdateTypes": ["major"],
"automerge": false,
"labels": ["major-update"]
}
],
"schedule": ["before 6am on Monday"]
}
+5 -5
View File
@@ -1,7 +1,7 @@
requests==2.34.2 requests==2.32.3
icalendar==6.3.2 icalendar==6.1.0
recurring-ical-events==3.8.2 recurring-ical-events==3.4.1
mysql-connector-python==9.7.0 mysql-connector-python==9.1.0
fastapi==0.115.0 fastapi==0.115.0
uvicorn[standard]==0.30.0 uvicorn[standard]==0.30.0
jinja2==3.1.6 jinja2==3.1.4
-216
View File
@@ -1,6 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
# Licensed under the MIT License. See LICENSE file in project root for details.
""" """
Google Calendar (privater ICS-Feed) -> MariaDB Sync Google Calendar (privater ICS-Feed) -> MariaDB Sync
Homelab: laeuft als Docker Container, pollt periodisch, expandiert RRULE/EXDATE/RECURRENCE-ID Homelab: laeuft als Docker Container, pollt periodisch, expandiert RRULE/EXDATE/RECURRENCE-ID
@@ -61,14 +59,6 @@ NOTIFY_EMAIL = os.environ.get("NOTIFY_EMAIL", "")
NOTIFY_TIME = int(os.environ.get("NOTIFY_TIME", "6")) NOTIFY_TIME = int(os.environ.get("NOTIFY_TIME", "6"))
NOTIFY_TIMEZONE = os.environ.get("NOTIFY_TIMEZONE", "Europe/Berlin") NOTIFY_TIMEZONE = os.environ.get("NOTIFY_TIMEZONE", "Europe/Berlin")
WEEKLY_NOTIFY_ENABLED = os.environ.get("WEEKLY_NOTIFY_ENABLED", "false").lower() == "true"
WEEKLY_NOTIFY_DAY = int(os.environ.get("WEEKLY_NOTIFY_DAY", "5"))
WEEKLY_NOTIFY_TIME = int(os.environ.get("WEEKLY_NOTIFY_TIME", "16"))
WEEKLY_NOTIFY_TIMEZONE = os.environ.get("WEEKLY_NOTIFY_TIMEZONE", "Europe/Berlin")
WEEKLY_NOTIFY_EMAIL = os.environ.get("WEEKLY_NOTIFY_EMAIL", "")
WEEKLY_SEARCHWORDS = os.environ.get("WEEKLY_SEARCHWORDS", "")
WEEKLY_BLACKLISTWORDS = os.environ.get("WEEKLY_BLACKLISTWORDS", "")
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.
@@ -139,15 +129,6 @@ def ensure_schema(conn):
UNIQUE KEY uk_date (notify_date) UNIQUE KEY uk_date (notify_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""") """)
cur.execute("""
CREATE TABLE IF NOT EXISTS weekly_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()
@@ -431,202 +412,6 @@ def check_and_send_notification():
conn.close() conn.close()
def parse_weekly_searchwords():
if not WEEKLY_SEARCHWORDS:
return []
return [w.strip() for w in WEEKLY_SEARCHWORDS.split(",") if w.strip()]
def parse_weekly_blacklistwords():
if not WEEKLY_BLACKLISTWORDS:
return []
return [w.strip() for w in WEEKLY_BLACKLISTWORDS.split(",") if w.strip()]
def get_weekly_events(conn, calendar_label, searchwords, blacklistwords=None):
tz = ZoneInfo(WEEKLY_NOTIFY_TIMEZONE)
now = datetime.now(tz)
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
days_until_saturday = (5 - today.weekday()) % 7
if days_until_saturday == 0:
days_until_saturday = 7
week_start = today + timedelta(days=days_until_saturday)
week_end = week_start + timedelta(days=13, hours=23, minutes=59, seconds=59)
week_start_naive = week_start.replace(tzinfo=None)
week_end_naive = week_end.replace(tzinfo=None)
search_clauses = []
search_params = []
for word in searchwords:
like = f"%{word}%"
search_clauses.append("(summary LIKE %s OR description LIKE %s OR location LIKE %s)")
search_params.extend([like, like, like])
where_search = " OR ".join(search_clauses) if search_clauses else "1=1"
blacklist_clauses = []
blacklist_params = []
for word in (blacklistwords or []):
like = f"%{word}%"
blacklist_clauses.append("(summary LIKE %s OR description LIKE %s OR location LIKE %s)")
blacklist_params.extend([like, like, like])
where_blacklist = ""
if blacklist_clauses:
where_blacklist = f"AND NOT ({' OR '.join(blacklist_clauses)})"
cur = conn.cursor(dictionary=True)
cur.execute(
f"""
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
AND ({where_search})
{where_blacklist}
ORDER BY start_at ASC
""",
(calendar_label, week_start_naive, week_end_naive, *search_params, *blacklist_params),
)
events = cur.fetchall()
cur.close()
return events, week_start, week_end
def should_send_weekly(conn):
if not WEEKLY_NOTIFY_ENABLED:
return False
if not parse_weekly_searchwords():
return False
tz = ZoneInfo(WEEKLY_NOTIFY_TIMEZONE)
now = datetime.now(tz)
if now.weekday() != WEEKLY_NOTIFY_DAY or now.hour != WEEKLY_NOTIFY_TIME:
return False
cur = conn.cursor()
cur.execute(
"SELECT COUNT(*) FROM weekly_notification_log WHERE notify_date = CURDATE()"
)
count = cur.fetchone()[0]
cur.close()
return count == 0
def log_weekly_notification(conn, event_count):
cur = conn.cursor()
cur.execute(
"INSERT INTO weekly_notification_log (notify_date, sent_at, event_count) VALUES (CURDATE(), NOW(), %s)",
(event_count,),
)
conn.commit()
cur.close()
def send_weekly_notification(events, searchwords, week_start, week_end, blacklistwords=None):
email = WEEKLY_NOTIFY_EMAIL or NOTIFY_EMAIL
if not SMTP_HOST or not email:
log.warning("SMTP-Konfiguration unvollstaendig, ueberspringe Wochenbenachrichtigung")
return
tz = ZoneInfo(WEEKLY_NOTIFY_TIMEZONE)
count = len(events)
start_str = week_start.strftime("%a, %d.%m.")
end_str = week_end.strftime("%a, %d.%m.")
if count == 1:
event = events[0]
event_date = event["start_at"].replace(tzinfo=timezone.utc).astimezone(tz).strftime("%a, %d.%m.")
subject = f"Vorab-Info: Termin am {event_date}"
else:
subject = f"Vorab-Info: {count} Termine in 2 Wochen"
event_rows = ""
for event in events:
time_str = format_event_time(event["start_at"], event.get("end_at"))
event_date = event["start_at"].replace(tzinfo=timezone.utc).astimezone(tz).strftime("%a, %d.%m.")
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;">{event_date}</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #eee; white-space: nowrap;">{time_str}</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #eee;">{summary}{location}</td>
</tr>"""
words_display = ", ".join(searchwords)
blacklist_display = ", ".join(blacklistwords) if blacklistwords else ""
blacklist_line = f"Ausgeschlossen: {blacklist_display}" if blacklist_display else ""
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;">Vorab-Info: Naechste 2 Wochen</h2>
<p style="color: #555;">Vom <strong>{start_str}</strong> bis <strong>{end_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;">Suchbegriffe: {words_display}{" " + blacklist_line if blacklist_line else ""}</p>
</body>
</html>
"""
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = SMTP_FROM
msg["To"] = 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, [email], msg.as_string())
server.quit()
log.info("Wochenbenachrichtigung gesendet: %s", subject)
except Exception:
log.exception("Fehler beim Senden der Wochenbenachrichtigung")
def check_and_send_weekly_notification():
if not SMTP_HOST or not (WEEKLY_NOTIFY_EMAIL or NOTIFY_EMAIL):
return
conn = get_connection(autocommit=True)
try:
if should_send_weekly(conn):
searchwords = parse_weekly_searchwords()
blacklistwords = parse_weekly_blacklistwords()
log.info("Wochenbenachrichtigung: Suchbegriffe=%s, Blacklist=%s", searchwords, blacklistwords)
events, week_start, week_end = get_weekly_events(conn, CALENDAR_LABEL, searchwords, blacklistwords)
log.info("Wochenbenachrichtigung: %d Events nach Filterung", len(events))
if events:
send_weekly_notification(events, searchwords, week_start, week_end, blacklistwords)
log_weekly_notification(conn, len(events))
log.info("Wochenbenachrichtigung fuer %d Events gesendet", len(events))
else:
log.info("Keine passenden Termine naechste Woche, Wochenbenachrichtigung wird uebersprungen")
except Exception:
log.exception("Fehler bei der Wochenbenachrichtigung")
finally:
conn.close()
def main(): def main():
bootstrap_database() bootstrap_database()
log.info( log.info(
@@ -638,7 +423,6 @@ def main():
try: try:
run_sync_once() run_sync_once()
check_and_send_notification() check_and_send_notification()
check_and_send_weekly_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)