mirror of
https://github.com/skoelle/calender_sync.git
synced 2026-09-18 10:40:25 +00:00
Compare commits
28
Commits
2fbfe31ffb
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78cafdd5dd | ||
|
|
cc803f2afe | ||
|
|
569ceccb93 | ||
|
|
3fccf6df67 | ||
|
|
0f16aa45dc | ||
|
|
3635820eb8 | ||
|
|
bc9570d5c2 | ||
|
|
82185a8290 | ||
|
|
cdc1570fac | ||
|
|
7c07a8f866 | ||
|
|
9bda384559 | ||
|
|
6c6848bb0d | ||
|
|
0ca20bab27 | ||
|
|
87353aa362 | ||
|
|
a5c02cd6e0 | ||
|
|
0b1cc9ed45 | ||
|
|
9af9b66447 | ||
|
|
66b9018b16 | ||
|
|
52de06bbbb | ||
|
|
e10c6fc169 | ||
|
|
946c2832ea | ||
|
|
7bfd7c0ff3 | ||
|
|
ac07e8f917 | ||
|
|
3b05342409 | ||
|
|
5343896653 | ||
|
|
f717d37d07 | ||
|
|
600d8262a8 | ||
|
|
a2fa805ce8 |
@@ -38,3 +38,12 @@ API_PORT=8000
|
||||
# NOTIFY_EMAIL=empfaenger@example.com
|
||||
# NOTIFY_TIME=6
|
||||
# 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
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v4
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
demo/demo.db
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
title: "Google Calender Sync"
|
||||
emoji: "📅"
|
||||
category: code
|
||||
subcategory: "Smart Home Apps"
|
||||
status: active
|
||||
stack: [Python, FastAPI, Uvicorn, Jinja2]
|
||||
@@ -9,7 +9,7 @@ Python-basierter Google Calendar → MariaDB Synchronizer. Läuft als Docker Con
|
||||
## Technologie-Stack
|
||||
|
||||
- **Sprache**: Python 3.12
|
||||
- **Datenbank**: MariaDB (mysql-connector-python)
|
||||
- **Datenbank**: MariaDB (mysql-connector-python) oder SQLite (stdlib) via `DB_BACKEND` Env-Var
|
||||
- **API**: FastAPI + Uvicorn + Jinja2
|
||||
- **Docker**: Multi-Stage Build nicht verwendet, simples Slim-Image
|
||||
- **Dependencies**: requests, icalendar, recurring-ical-events, mysql-connector-python, fastapi, uvicorn, jinja2
|
||||
@@ -21,9 +21,12 @@ Python-basierter Google Calendar → MariaDB Synchronizer. Läuft als Docker Con
|
||||
├── sync.py # Hauptskript (alles in einer Datei)
|
||||
├── api/
|
||||
│ ├── main.py # FastAPI App (REST API + HTML UI)
|
||||
│ ├── database.py # DB-Verbindung für die API
|
||||
│ ├── database.py # DB-Verbindung (MySQL + SQLite)
|
||||
│ └── templates/
|
||||
│ └── 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
|
||||
├── Dockerfile # Docker Image Definition
|
||||
├── docker-compose.yml # Docker Compose Konfiguration
|
||||
@@ -69,9 +72,21 @@ Sendet täglich um konfigurierte Uhrzeit eine HTML-Email mit anstehenden Termine
|
||||
- Konfiguration über SMTP_* und NOTIFY_* Umgebungsvariablen
|
||||
- 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
|
||||
|
||||
### Lokaler Test
|
||||
### Lokaler Test (Docker)
|
||||
```bash
|
||||
# Dependencies installieren
|
||||
pip install -r requirements.txt
|
||||
@@ -83,6 +98,20 @@ cp .env.example .env
|
||||
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
|
||||
Keine Linting/Type-Checking Tools konfiguriert. Bei Bedarf hinzufügen:
|
||||
- `ruff` für Linting
|
||||
@@ -114,3 +143,9 @@ GitHub Actions Workflow:
|
||||
- Baut Docker Image bei Push zu `main`
|
||||
- Published nach `ghcr.io/skoelle/calender_sync:latest`
|
||||
- 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
|
||||
|
||||
@@ -5,6 +5,7 @@ WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY LICENSE .
|
||||
COPY sync.py .
|
||||
COPY api/ ./api/
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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.
|
||||
@@ -1,29 +1,32 @@
|
||||
# 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.
|
||||
|
||||
## Features
|
||||
## ✨ Features
|
||||
|
||||
- Automatische Synchronisation alle 15 Minuten (konfigurierbar)
|
||||
- Expandiert wiederkehrende Events (RRULE) und Serien-Exceptions (EXDATE, RECURRENCE-ID)
|
||||
- Soft-Delete: Entfernte Events werden als `deleted=1` markiert, nicht gelöscht
|
||||
- Optionales Database-Bootstrap: Erstellt DB und User automatisch bei `DB_BOOTSTRAP=true`
|
||||
- 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
|
||||
- 🔄 Automatische Synchronisation alle 15 Minuten (konfigurierbar)
|
||||
- 📆 Expandiert wiederkehrende Events (RRULE) und Serien-Exceptions (EXDATE, RECURRENCE-ID)
|
||||
- 🗑️ Soft-Delete: Entfernte Events werden als `deleted=1` markiert, nicht gelöscht
|
||||
- 🛠️ Optionales Database-Bootstrap: Erstellt DB und User automatisch bei `DB_BOOTSTRAP=true`
|
||||
- ⏰ 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
|
||||
- 📬 Optionale wöchentliche Vorab-Info (z.B. freitags) mit Termine nach Suchbegriffen
|
||||
|
||||
## Voraussetzungen
|
||||
[](docs/screenshot.png)
|
||||
|
||||
- Docker & Docker Compose
|
||||
- MariaDB Instanz (z.B. als Proxmox LXC)
|
||||
- Google Calendar mit privatem ICS-Feed
|
||||
## 📋 Voraussetzungen
|
||||
|
||||
## Quick Start
|
||||
- 🐳 Docker & Docker Compose
|
||||
- 🗃️ MariaDB Instanz (z.B. als Proxmox LXC)
|
||||
- 📱 Google Calendar mit privatem ICS-Feed
|
||||
|
||||
1. **MariaDB Setup**
|
||||
## 🚀 Quick Start
|
||||
|
||||
1. **🔧 MariaDB Setup**
|
||||
|
||||
Entweder manuell ausführen:
|
||||
```bash
|
||||
@@ -37,7 +40,7 @@ Läuft als Docker Container, pollt periodisch einen privaten Google Calendar ICS
|
||||
DB_ROOT_PASSWORD=dein_root_passwort
|
||||
```
|
||||
|
||||
2. **.env anlegen**
|
||||
2. **📄 .env anlegen**
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
@@ -47,13 +50,38 @@ Läuft als Docker Container, pollt periodisch einen privaten Google Calendar ICS
|
||||
- `ICS_URL`: Privater ICS-Link aus den Google Calendar Einstellungen
|
||||
- `DB_PASSWORD`: Sicheres Passwort für den calendar_sync User
|
||||
|
||||
3. **Starten**
|
||||
3. **▶️ Starten**
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Konfiguration
|
||||
## 🎮 Demo / Lokaler Test (ohne Docker)
|
||||
|
||||
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:00–15:00 | ✅ CONFIRMED |
|
||||
| 🧘 Yoga Kurs | heute 18:30–20:00 | ✅ CONFIRMED |
|
||||
| ⚠️ Projekt-Deadline | morgen Ganztag | 🟡 TENTATIVE |
|
||||
| 👨⚕️ Arzttermin | übermorgen 10:00–11:30 | ✅ CONFIRMED |
|
||||
|
||||
Die Demo verwendet SQLite (`DB_BACKEND=sqlite`) statt MariaDB. Die Events werden relativ zum heutigen Datum erstellt.
|
||||
|
||||
## ⚙️ Konfiguration
|
||||
|
||||
| Variable | Default | Beschreibung |
|
||||
|----------|---------|--------------|
|
||||
@@ -74,7 +102,7 @@ Läuft als Docker Container, pollt periodisch einen privaten Google Calendar ICS
|
||||
| `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
|
||||
### 📧 Optionale E-Mail-Benachrichtigung
|
||||
|
||||
Sendet täglich eine HTML-Email mit den anstehenden Terminen. Wird aktiviert wenn `SMTP_HOST` und `NOTIFY_EMAIL` gesetzt sind.
|
||||
|
||||
@@ -90,58 +118,94 @@ Sendet täglich eine HTML-Email mit den anstehenden Terminen. Wird aktiviert wen
|
||||
| `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`
|
||||
**📬 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.
|
||||
**⚠️ Hinweis:** Ganztagstermine werden nicht in der Benachrichtigung berücksichtigt.
|
||||
|
||||
## Datenbank-Schema
|
||||
### 📬 Optionale wöchentliche Vorab-Info
|
||||
|
||||
Tabelle `calendar_events`:
|
||||
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.
|
||||
|
||||
| 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 |
|
||||
|--------|-----|--------------|
|
||||
| `id` | BIGINT PK | Auto-Increment |
|
||||
| `calendar_label` | VARCHAR(64) | Kalender-Label |
|
||||
| `instance_key` | VARCHAR(255) | SHA1-basierte eindeutige Instanz-ID |
|
||||
| `uid` | VARCHAR(255) | ICS UID |
|
||||
| `recurrence_id` | VARCHAR(64) | RECURRENCE-ID bei Serien-Exceptions |
|
||||
| `summary` | VARCHAR(512) | Titel |
|
||||
| `description` | TEXT | Beschreibung |
|
||||
| `location` | VARCHAR(512) | Ort |
|
||||
| `start_at` | DATETIME | Startzeit (UTC) |
|
||||
| `end_at` | DATETIME | Endzeit (UTC) |
|
||||
| `all_day` | TINYINT(1) | Ganz-tagig Flag |
|
||||
| `status` | VARCHAR(32) | Event-Status |
|
||||
| `deleted` | TINYINT(1) | Soft-Delete Flag |
|
||||
| `last_seen_at` | DATETIME | Letzte Synchronisation |
|
||||
| `created_at` | DATETIME | Erstellungszeitpunkt |
|
||||
| `updated_at` | DATETIME | Letzte Änderung |
|
||||
| `id` | 🆔 BIGINT PK | Auto-Increment |
|
||||
| `calendar_label` | 🏷️ VARCHAR(64) | Kalender-Label |
|
||||
| `instance_key` | 🔑 VARCHAR(255) | SHA1-basierte eindeutige Instanz-ID |
|
||||
| `uid` | 📌 VARCHAR(255) | ICS UID |
|
||||
| `recurrence_id` | 🔄 VARCHAR(64) | RECURRENCE-ID bei Serien-Exceptions |
|
||||
| `summary` | 📝 VARCHAR(512) | Titel |
|
||||
| `description` | 📄 TEXT | Beschreibung |
|
||||
| `location` | 📍 VARCHAR(512) | Ort |
|
||||
| `start_at` | 🚀 DATETIME | Startzeit (UTC) |
|
||||
| `end_at` | 🏁 DATETIME | Endzeit (UTC) |
|
||||
| `all_day` | ☀️ TINYINT(1) | Ganz-tagig Flag |
|
||||
| `status` | 📊 VARCHAR(32) | Event-Status |
|
||||
| `deleted` | 🗑️ TINYINT(1) | Soft-Delete Flag |
|
||||
| `last_seen_at` | 👁️ DATETIME | Letzte Synchronisation |
|
||||
| `created_at` | 🎨 DATETIME | Erstellungszeitpunkt |
|
||||
| `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 |
|
||||
|--------|-----|--------------|
|
||||
| `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 |
|
||||
| `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
|
||||
**Tabelle `weekly_notification_log` (optional, für wöchentliche Vorab-Info):**
|
||||
|
||||
| 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.
|
||||
|
||||
### Endpoints
|
||||
### 🔗 Endpoints
|
||||
|
||||
| Endpoint | Beschreibung |
|
||||
|----------|--------------|
|
||||
| `GET /` | HTML-Seite mit anstehenden Terminen und Suchfunktion |
|
||||
| `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/{event_id}` | Einzelnes Event als JSON |
|
||||
| `GET /` | 🌐 HTML-Seite mit anstehenden Terminen und Suchfunktion |
|
||||
| `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/{event_id}` | 🔍 Einzelnes Event als JSON |
|
||||
|
||||
### API Beispiel
|
||||
### 💡 API Beispiel
|
||||
|
||||
```bash
|
||||
# Alle anstehenden Events (max. 10)
|
||||
@@ -154,7 +218,7 @@ curl "http://localhost:8000/api/events?search=Meeting&limit=5"
|
||||
curl http://localhost:8000/api/events/42
|
||||
```
|
||||
|
||||
### JSON Response Format
|
||||
### 📋 JSON Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -177,7 +241,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:
|
||||
|
||||
@@ -185,6 +249,6 @@ GitHub Actions Workflow (`docker-publish.yml`) baut und published das Docker Ima
|
||||
ghcr.io/skoelle/calender_sync:latest
|
||||
```
|
||||
|
||||
## Lizenz
|
||||
## 📜 Lizenz
|
||||
|
||||
Keine Lizenz angegeben.
|
||||
Lizenziert unter der [MIT License](LICENSE) - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||
# Licensed under the MIT License. See LICENSE file in project root for details.
|
||||
|
||||
+19
-1
@@ -1,4 +1,23 @@
|
||||
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||
# Licensed under the MIT License. See LICENSE file in project root for details.
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
DB_BACKEND = os.environ.get("DB_BACKEND", "mysql")
|
||||
|
||||
if DB_BACKEND == "sqlite":
|
||||
DB_PATH = os.environ.get("DB_PATH", "demo.db")
|
||||
|
||||
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")
|
||||
@@ -7,7 +26,6 @@ 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(
|
||||
host=DB_HOST,
|
||||
|
||||
+16
-7
@@ -1,3 +1,6 @@
|
||||
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||
# Licensed under the MIT License. See LICENSE file in project root for details.
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
@@ -9,10 +12,13 @@ from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.database import get_connection
|
||||
from api.database import get_connection, DB_BACKEND
|
||||
|
||||
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")
|
||||
|
||||
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
|
||||
@@ -32,6 +38,8 @@ def to_local_timezone(dt: datetime) -> datetime:
|
||||
"""Konvertiere naive UTC datetime zu Benutzer-Zeitzone."""
|
||||
if dt is None:
|
||||
return None
|
||||
if isinstance(dt, str):
|
||||
dt = datetime.fromisoformat(dt)
|
||||
return dt.replace(tzinfo=timezone.utc).astimezone(TIMEZONE)
|
||||
|
||||
|
||||
@@ -81,19 +89,19 @@ def fetch_events(limit: int = 10, search: str | None = None, calendar_label: str
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
conditions = ["deleted = 0", "start_at >= NOW()"]
|
||||
conditions = [f"deleted = 0", f"start_at >= {NOW_SQL}"]
|
||||
params: list = []
|
||||
if search:
|
||||
conditions.append("summary LIKE %s")
|
||||
conditions.append(f"summary LIKE {PH}")
|
||||
params.append(f"%{search}%")
|
||||
if calendar_label:
|
||||
conditions.append("calendar_label = %s")
|
||||
conditions.append(f"calendar_label = {PH}")
|
||||
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",
|
||||
f"{where} ORDER BY start_at ASC LIMIT {PH}",
|
||||
tuple(params),
|
||||
)
|
||||
return cur.fetchall()
|
||||
@@ -138,7 +146,7 @@ def get_event(event_id: int):
|
||||
try:
|
||||
cur.execute(
|
||||
f"SELECT {SELECT_COLUMNS} FROM calendar_events "
|
||||
"WHERE id = %s AND deleted = 0",
|
||||
f"WHERE id = {PH} AND deleted = 0",
|
||||
(event_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
@@ -183,6 +191,7 @@ def index(
|
||||
})
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"index.html",
|
||||
{"request": request, "events": events, "search": search or ""},
|
||||
{"events": events, "search": search or ""},
|
||||
)
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
}
|
||||
|
||||
.event-location::before {
|
||||
content: "\\1F4CD ";
|
||||
content: "\1F4CD ";
|
||||
}
|
||||
|
||||
.badge {
|
||||
@@ -163,7 +163,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Termine</h1>
|
||||
<h1>📆️ Termine</h1>
|
||||
|
||||
<form class="search-form" method="get" action="/">
|
||||
<input type="text" name="search" value="{{ search }}" placeholder="Suche nach Titel...">
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/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
@@ -0,0 +1,117 @@
|
||||
#!/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()
|
||||
@@ -36,6 +36,14 @@ services:
|
||||
- NOTIFY_TIME=${NOTIFY_TIME:-6}
|
||||
- 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:
|
||||
- docker-backend
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$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
@@ -1,7 +1,7 @@
|
||||
requests==2.32.3
|
||||
icalendar==6.1.0
|
||||
recurring-ical-events==3.4.1
|
||||
mysql-connector-python==9.1.0
|
||||
requests==2.34.2
|
||||
icalendar==6.3.2
|
||||
recurring-ical-events==3.8.2
|
||||
mysql-connector-python==9.7.0
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.0
|
||||
jinja2==3.1.4
|
||||
jinja2==3.1.6
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#!/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
|
||||
Homelab: laeuft als Docker Container, pollt periodisch, expandiert RRULE/EXDATE/RECURRENCE-ID
|
||||
@@ -59,6 +61,14 @@ 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")
|
||||
|
||||
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():
|
||||
"""Legt DB_NAME und DB_USER an, falls sie noch nicht existieren.
|
||||
@@ -129,6 +139,15 @@ def ensure_schema(conn):
|
||||
UNIQUE KEY uk_date (notify_date)
|
||||
) 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()
|
||||
cur.close()
|
||||
|
||||
@@ -412,6 +431,202 @@ def check_and_send_notification():
|
||||
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():
|
||||
bootstrap_database()
|
||||
log.info(
|
||||
@@ -423,6 +638,7 @@ def main():
|
||||
try:
|
||||
run_sync_once()
|
||||
check_and_send_notification()
|
||||
check_and_send_weekly_notification()
|
||||
except Exception:
|
||||
log.exception("Sync-Durchlauf fehlgeschlagen, versuche es beim naechsten Intervall erneut")
|
||||
time.sleep(SYNC_INTERVAL_MINUTES * 60)
|
||||
|
||||
Reference in New Issue
Block a user