Compare commits

..
12 Commits
Author SHA1 Message Date
stefankoelle 7c07a8f866 renovate 2026-08-10 19:26:34 +02:00
Stefan Koelle 9bda384559 Merge pull request #6 from skoelle/renovate/recurring-ical-events-3.x
chore(deps): update dependency recurring-ical-events to v3.8.2
2026-08-10 19:26:09 +02:00
renovate[bot] 6c6848bb0d chore(deps): update dependency recurring-ical-events to v3.8.2 2026-08-10 17:25:50 +00:00
Stefan Koelle 0ca20bab27 Merge pull request #3 from skoelle/renovate/jinja2-3.x
chore(deps): update dependency jinja2 to v3.1.6
2026-08-10 19:25:40 +02:00
Stefan Koelle 87353aa362 Merge pull request #5 from skoelle/renovate/mysql-connector-python-9.x
chore(deps): update dependency mysql-connector-python to v9.7.0
2026-08-10 19:25:00 +02:00
Stefan Koelle a5c02cd6e0 Merge pull request #4 from skoelle/renovate/icalendar-6.x
chore(deps): update dependency icalendar to v6.3.2
2026-08-10 19:24:41 +02:00
renovate[bot] 0b1cc9ed45 chore(deps): update dependency mysql-connector-python to v9.7.0 2026-08-10 17:24:26 +00:00
renovate[bot] 9af9b66447 chore(deps): update dependency icalendar to v6.3.2 2026-08-10 17:24:20 +00:00
renovate[bot] 66b9018b16 chore(deps): update dependency jinja2 to v3.1.6 2026-08-10 17:24:16 +00:00
stefankoelle 52de06bbbb screenshot 2026-08-10 17:10:05 +02:00
stefankoelle e10c6fc169 demo showcase README.md 2026-08-10 17:01:27 +02:00
stefankoelle 946c2832ea demo showcase 2026-08-10 16:47:13 +02:00
12 changed files with 287 additions and 30 deletions
+2
View File
@@ -1,3 +1,5 @@
.env .env
__pycache__/ __pycache__/
*.pyc *.pyc
.venv/
demo/demo.db
+20 -3
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) - **Datenbank**: MariaDB (mysql-connector-python) oder SQLite (stdlib) via `DB_BACKEND` Env-Var
- **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,9 +21,12 @@ 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 für die API │ ├── database.py # DB-Verbindung (MySQL + SQLite)
│ └── 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
@@ -71,7 +74,7 @@ Sendet täglich um konfigurierte Uhrzeit eine HTML-Email mit anstehenden Termine
## Entwicklung ## Entwicklung
### Lokaler Test ### Lokaler Test (Docker)
```bash ```bash
# Dependencies installieren # Dependencies installieren
pip install -r requirements.txt pip install -r requirements.txt
@@ -83,6 +86,20 @@ 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
+27
View File
@@ -15,6 +15,8 @@ Läuft als Docker Container, pollt periodisch einen privaten Google Calendar ICS
- 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
[![UI](docs/screenshot_thumbnail.png)](docs/screenshot.png)
## Voraussetzungen ## Voraussetzungen
- Docker & Docker Compose - Docker & Docker Compose
@@ -53,6 +55,31 @@ Läuft als Docker Container, pollt periodisch einen privaten Google Calendar ICS
docker compose up -d docker compose up -d
``` ```
## 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: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 ## Konfiguration
| Variable | Default | Beschreibung | | Variable | Default | Beschreibung |
+16 -1
View File
@@ -2,6 +2,22 @@
# Licensed under the MIT License. See LICENSE file in project root for details. # Licensed under the MIT License. See LICENSE file in project root for details.
import os 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 import mysql.connector
DB_HOST = os.environ.get("DB_HOST", "mariadb.fritz.box") DB_HOST = os.environ.get("DB_HOST", "mariadb.fritz.box")
@@ -10,7 +26,6 @@ DB_NAME = os.environ.get("DB_NAME", "calendar_sync")
DB_USER = os.environ["DB_USER"] DB_USER = os.environ["DB_USER"]
DB_PASSWORD = os.environ["DB_PASSWORD"] DB_PASSWORD = os.environ["DB_PASSWORD"]
def get_connection(autocommit: bool = True): def get_connection(autocommit: bool = True):
return mysql.connector.connect( return mysql.connector.connect(
host=DB_HOST, host=DB_HOST,
+13 -7
View File
@@ -12,10 +12,13 @@ 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 from api.database import get_connection, DB_BACKEND
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")
@@ -35,6 +38,8 @@ 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)
@@ -84,19 +89,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 = ["deleted = 0", "start_at >= NOW()"] conditions = [f"deleted = 0", f"start_at >= {NOW_SQL}"]
params: list = [] params: list = []
if search: if search:
conditions.append("summary LIKE %s") conditions.append(f"summary LIKE {PH}")
params.append(f"%{search}%") params.append(f"%{search}%")
if calendar_label: if calendar_label:
conditions.append("calendar_label = %s") conditions.append(f"calendar_label = {PH}")
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 %s", f"{where} ORDER BY start_at ASC LIMIT {PH}",
tuple(params), tuple(params),
) )
return cur.fetchall() return cur.fetchall()
@@ -141,7 +146,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 "
"WHERE id = %s AND deleted = 0", f"WHERE id = {PH} AND deleted = 0",
(event_id,), (event_id,),
) )
row = cur.fetchone() row = cur.fetchone()
@@ -186,6 +191,7 @@ def index(
}) })
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"index.html", "index.html",
{"request": request, "events": events, "search": search or ""}, {"events": events, "search": search or ""},
) )
+1 -1
View File
@@ -116,7 +116,7 @@
} }
.event-location::before { .event-location::before {
content: "\\1F4CD "; content: "\1F4CD ";
} }
.badge { .badge {
Executable
+38
View File
@@ -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
View File
@@ -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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+35
View File
@@ -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"]
}
+4 -4
View File
@@ -1,7 +1,7 @@
requests==2.32.3 requests==2.32.3
icalendar==6.1.0 icalendar==6.3.2
recurring-ical-events==3.4.1 recurring-ical-events==3.8.2
mysql-connector-python==9.1.0 mysql-connector-python==9.7.0
fastapi==0.115.0 fastapi==0.115.0
uvicorn[standard]==0.30.0 uvicorn[standard]==0.30.0
jinja2==3.1.4 jinja2==3.1.6