Compare commits

...
9 Commits
Author SHA1 Message Date
stefankoelle 78cafdd5dd Weekly Notification: Zeitraum auf 2 Wochen erweitern 2026-09-13 14:31:40 +02:00
stefankoelle cc803f2afe fix docker-compose 2026-08-22 09:28:18 +02:00
stefankoelle 569ceccb93 new feature: weekly blacklistwords 2026-08-18 18:22:46 +02:00
stefankoelle 3fccf6df67 README.md 2026-08-14 23:43:40 +02:00
stefankoelle 0f16aa45dc mailer weekly notifier 2026-08-11 22:04:11 +02:00
Stefan Koelle 3635820eb8 Merge pull request #8 from skoelle/renovate/major-github-actions
chore(deps): update actions/checkout action to v7
2026-08-10 20:15:09 +02:00
renovate[bot] bc9570d5c2 chore(deps): update actions/checkout action to v7 2026-08-10 18:14:05 +00:00
Stefan Koelle 82185a8290 Merge pull request #7 from skoelle/renovate/requests-2.x
chore(deps): update dependency requests to v2.34.2
2026-08-10 19:39:45 +02:00
renovate[bot] cdc1570fac chore(deps): update dependency requests to v2.34.2 2026-08-10 17:27:10 +00:00
8 changed files with 356 additions and 70 deletions
+9
View File
@@ -38,3 +38,12 @@ 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@v6 uses: actions/checkout@v7
- name: Log in to GHCR - name: Log in to GHCR
uses: docker/login-action@v4 uses: docker/login-action@v4
+6
View File
@@ -0,0 +1,6 @@
title: "Google Calender Sync"
emoji: "📅"
category: code
subcategory: "Smart Home Apps"
status: active
stack: [Python, FastAPI, Uvicorn, Jinja2]
+12
View File
@@ -72,6 +72,18 @@ 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 (Docker)
+105 -68
View File
@@ -1,31 +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. 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) [![UI](docs/screenshot_thumbnail.png)](docs/screenshot.png)
## Voraussetzungen ## 📋 Voraussetzungen
- Docker & Docker Compose - 🐳 Docker & Docker Compose
- MariaDB Instanz (z.B. als Proxmox LXC) - 🗃️ MariaDB Instanz (z.B. als Proxmox LXC)
- Google Calendar mit privatem ICS-Feed - 📱 Google Calendar mit privatem ICS-Feed
## Quick Start ## 🚀 Quick Start
1. **MariaDB Setup** 1. **🔧 MariaDB Setup**
Entweder manuell ausführen: Entweder manuell ausführen:
```bash ```bash
@@ -39,7 +40,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
@@ -49,13 +50,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) ## 🎮 Demo / Lokaler Test (ohne Docker)
Schneller Weg um Web-UI und API lokal zu testen, ohne MariaDB oder Docker: Schneller Weg um Web-UI und API lokal zu testen, ohne MariaDB oder Docker:
@@ -65,22 +66,22 @@ Schneller Weg um Web-UI und API lokal zu testen, ohne MariaDB oder Docker:
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. 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:** **🔗 Endpoints:**
- Web-UI: http://localhost:8000/ - 🌐 Web-UI: http://localhost:8000/
- API JSON: http://localhost:8000/api/events - 📊 API JSON: http://localhost:8000/api/events
- Health: http://localhost:8000/api/health - ❤️ Health: http://localhost:8000/api/health
**Demo-Events (4 Stück):** **🎯 Demo-Events (4 Stück):**
| Termin | Zeit | Status | | Termin | Zeit | Status |
|--------|------|--------| |--------|------|--------|
| Team Daily | heute 14:0015:00 | CONFIRMED | | 📅 Team Daily | heute 14:0015:00 | CONFIRMED |
| Yoga Kurs | heute 18:3020:00 | CONFIRMED | | 🧘 Yoga Kurs | heute 18:3020:00 | CONFIRMED |
| Projekt-Deadline | morgen Ganztag | TENTATIVE | | ⚠️ Projekt-Deadline | morgen Ganztag | 🟡 TENTATIVE |
| Arzttermin | übermorgen 10:0011:30 | CONFIRMED | | 👨‍⚕️ Arzttermin | übermorgen 10:0011:30 | CONFIRMED |
Die Demo verwendet SQLite (`DB_BACKEND=sqlite`) statt MariaDB. Die Events werden relativ zum heutigen Datum erstellt. Die Demo verwendet SQLite (`DB_BACKEND=sqlite`) statt MariaDB. Die Events werden relativ zum heutigen Datum erstellt.
## Konfiguration ## ⚙️ Konfiguration
| Variable | Default | Beschreibung | | Variable | Default | Beschreibung |
|----------|---------|--------------| |----------|---------|--------------|
@@ -101,7 +102,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.
@@ -117,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_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.
## 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 | | 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 |
## 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. 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)
@@ -181,7 +218,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
{ {
@@ -204,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: GitHub Actions Workflow (`docker-publish.yml`) baut und published das Docker Image automatisch nach GitHub Container Registry:
@@ -212,6 +249,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) Lizenziert unter der [MIT License](LICENSE) - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
+8
View File
@@ -36,6 +36,14 @@ 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
+1 -1
View File
@@ -1,4 +1,4 @@
requests==2.32.3 requests==2.34.2
icalendar==6.3.2 icalendar==6.3.2
recurring-ical-events==3.8.2 recurring-ical-events==3.8.2
mysql-connector-python==9.7.0 mysql-connector-python==9.7.0
+214
View File
@@ -61,6 +61,14 @@ 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.
@@ -131,6 +139,15 @@ 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()
@@ -414,6 +431,202 @@ 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(
@@ -425,6 +638,7 @@ 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)