diff --git a/.gitignore b/.gitignore index cff5543..1619e41 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .env __pycache__/ *.pyc +.venv/ +demo/demo.db diff --git a/api/database.py b/api/database.py index 7439b47..dcfe845 100644 --- a/api/database.py +++ b/api/database.py @@ -2,21 +2,36 @@ # Licensed under the MIT License. See LICENSE file in project root for details. import os -import mysql.connector +import sqlite3 -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"] +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): - return mysql.connector.connect( - host=DB_HOST, - port=DB_PORT, - user=DB_USER, - password=DB_PASSWORD, - database=DB_NAME, - autocommit=autocommit, - ) + 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( + host=DB_HOST, + port=DB_PORT, + user=DB_USER, + password=DB_PASSWORD, + database=DB_NAME, + autocommit=autocommit, + ) diff --git a/api/main.py b/api/main.py index 231cf2c..8262c61 100644 --- a/api/main.py +++ b/api/main.py @@ -12,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") @@ -35,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) @@ -84,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() @@ -141,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() @@ -186,6 +191,7 @@ def index( }) return templates.TemplateResponse( + request, "index.html", - {"request": request, "events": events, "search": search or ""}, + {"events": events, "search": search or ""}, ) diff --git a/demo.sh b/demo.sh new file mode 100755 index 0000000..8919c2f --- /dev/null +++ b/demo.sh @@ -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 diff --git a/demo/seed.py b/demo/seed.py new file mode 100644 index 0000000..ced3f00 --- /dev/null +++ b/demo/seed.py @@ -0,0 +1,112 @@ +#!/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) + + events = [ + { + "calendar_label": "demo", + "instance_key": f"team-daily-{today.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": (today + timedelta(hours=14)).strftime("%Y-%m-%d %H:%M:%S"), + "end_at": (today + timedelta(hours=15)).strftime("%Y-%m-%d %H:%M:%S"), + "all_day": 0, + "status": "CONFIRMED", + }, + { + "calendar_label": "demo", + "instance_key": f"yoga-{today.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": (today + timedelta(hours=18, minutes=30)).strftime("%Y-%m-%d %H:%M:%S"), + "end_at": (today + timedelta(hours=20)).strftime("%Y-%m-%d %H:%M:%S"), + "all_day": 0, + "status": "CONFIRMED", + }, + { + "calendar_label": "demo", + "instance_key": f"deadline-{(today + 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": (today + timedelta(days=1)).strftime("%Y-%m-%d 00:00:00"), + "end_at": (today + timedelta(days=1)).strftime("%Y-%m-%d 23:59:59"), + "all_day": 1, + "status": "TENTATIVE", + }, + { + "calendar_label": "demo", + "instance_key": f"arzt-{(today + 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": (today + timedelta(days=2, hours=10)).strftime("%Y-%m-%d %H:%M:%S"), + "end_at": (today + 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()