From b30c02beb8eecb0ba54f0c67f15c4d9e3c8344de Mon Sep 17 00:00:00 2001 From: Stefan Koelle Date: Sat, 1 Aug 2026 16:12:07 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20REST=20API=20+=20Web-Frontend=20f?= =?UTF-8?q?=C3=BCr=20Kalender=C3=BCbersicht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FastAPI Backend mit /api/events, /api/events/{id}, /api/health Endpoints - Optionale Suche nach Event-Titel (Query Parameter ?search=...) - Jinja2 Web-Frontend auf / mit Suchfeld - Shared DB Connection Module (api/database.py) - Docker Compose: calendar-api Service hinzugefügt - Sync.py refactored: nutzt shared database.py --- .env.example | 3 + Dockerfile | 1 + PLAN.md | 133 +++++++++++++++++++ SPEC.md | 272 +++++++++++++++++++++++++++++++++++++++ api/__init__.py | 0 api/database.py | 19 +++ api/main.py | 171 ++++++++++++++++++++++++ api/templates/index.html | 214 ++++++++++++++++++++++++++++++ docker-compose.yml | 21 +++ requirements.txt | 3 + sync.py | 15 +-- 11 files changed, 840 insertions(+), 12 deletions(-) create mode 100644 PLAN.md create mode 100644 SPEC.md create mode 100644 api/__init__.py create mode 100644 api/database.py create mode 100644 api/main.py create mode 100644 api/templates/index.html diff --git a/.env.example b/.env.example index a2089a7..ba8a48a 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,6 @@ LOG_LEVEL=INFO # Optional: Healthchecks.io / Uptime Kuma URL (wird nach jedem Sync gepingt) # HEALTHCHECK_URL=https://hc-ping.com/DEINE_UUID + +# API Server +API_PORT=8000 diff --git a/Dockerfile b/Dockerfile index 988c193..a77286b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,7 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY sync.py . +COPY api/ ./api/ ENV PYTHONUNBUFFERED=1 diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..510c817 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,133 @@ +# PLAN.md - Implementierungsplan + +Feature: REST API + Web-Frontend für Calendar Sync + +## Übersicht + +Ziel: FastAPI-basierte API und ein Jinja2 Web-Frontend hinzufügen, um die nächsten 10 Termine aus MariaDB auszulesen und anzuzeigen. Gleicher Docker Build, separater Container. + +--- + +## Phase 1: Projektstruktur + Shared Module + +### Step 1.1: API-Verzeichnisstruktur anlegen +``` +api/ +├── __init__.py +├── main.py +├── database.py +└── templates/ + └── index.html +``` + +### Step 1.2: database.py - DB Connection extrahieren +- `get_connection()` Funktion aus `sync.py:81-89` in `api/database.py` verschieben +- Connection Pooling optional (First: einfacher single connection) +- Umgebungsvariablen identisch zu sync.py +- sync.py importiert dann `from api.database import get_connection` + +**Dateien:** `api/__init__.py`, `api/database.py`, `sync.py` (Import anpassen) + +--- + +## Phase 2: FastAPI Backend + +### Step 2.1: api/main.py - FastAPI App erstellen +- FastAPI Instanz erstellen +- GET `/api/events` Endpoint + - Query Parameter: `limit` (default 10, max 50), `calendar_label` (optional), `search` (optional) + - SQL bei search: `WHERE deleted=0 AND start_at >= NOW() AND summary LIKE %s ORDER BY start_at ASC LIMIT %s` + - SQL ohne search: `WHERE deleted=0 AND start_at >= NOW() ORDER BY start_at ASC LIMIT %s` + - Response als JSON +- GET `/api/events/{id}` Endpoint + - Einzelnes Event nach ID +- GET `/api/health` Endpoint + - Response: `{"status": "ok"}` +- GET `/` Endpoint + - Jinja2 Template rendern mit Events + - Query Parameter `search` weiterleiten + +### Step 2.2: Response Model definieren +- Pydantic Model für Event Response +- DATETIME → String Konvertierung (ISO Format) + +**Dateien:** `api/main.py` + +--- + +## Phase 3: Web-Frontend + +### Step 3.1: api/templates/index.html +- Einfaches HTML5 Template +- Jinja2 Variablen: `{{ events }}`, `{{ search }}` +- CSS inline oder im ` + + +
+

Termine

+ +
+ + + {% if search %} + Zurücksetzen + {% endif %} +
+ +
+ {% if events %} + {% for event in events %} +
+
+
+
+ {% if event.all_day %} + {{ event.start_at.strftime('%d.%m.%Y') }} Ganztägig + {% elif event.end_at %} + {{ event.start_at.strftime('%d.%m.%Y %H:%M') }} - {{ event.end_at.strftime('%H:%M') }} + {% else %} + {{ event.start_at.strftime('%d.%m.%Y %H:%M') }} + {% endif %} +
+
{{ event.summary or '(Kein Titel)' }}
+ {% if event.location %} +
{{ event.location }}
+ {% endif %} +
+ + {{ event.status }} + +
+
+ {% endfor %} + {% else %} +
+ {% if search %} + Keine Termine für "{{ search }}" gefunden. + {% else %} + Keine anstehenden Termine. + {% endif %} +
+ {% endif %} +
+
+ + diff --git a/docker-compose.yml b/docker-compose.yml index bba22fa..598b5d7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,7 @@ services: image: ghcr.io/skoelle/calender_sync:latest container_name: calendar-sync restart: unless-stopped + command: ["python", "sync.py"] environment: - ICS_URL=${ICS_URL} @@ -22,6 +23,26 @@ services: - LOG_LEVEL=${LOG_LEVEL:-INFO} - HEALTHCHECK_URL=${HEALTHCHECK_URL:-} + networks: + - docker-backend + + calendar-api: + image: ghcr.io/skoelle/calender_sync:latest + container_name: calendar-api + restart: unless-stopped + command: ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"] + + ports: + - "${API_PORT:-8000}:8000" + + environment: + - DB_HOST=${DB_HOST:-mariadb.fritz.box} + - DB_PORT=${DB_PORT:-3306} + - DB_NAME=${DB_NAME:-calendar_sync} + - DB_USER=${DB_USER} + - DB_PASSWORD=${DB_PASSWORD} + - LOG_LEVEL=${LOG_LEVEL:-INFO} + labels: - "com.centurylinklabs.watchtower.enable=true" diff --git a/requirements.txt b/requirements.txt index 1e6d0be..8fc800c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,6 @@ requests==2.32.3 icalendar==6.1.0 recurring-ical-events==3.4.1 mysql-connector-python==9.1.0 +fastapi==0.115.0 +uvicorn[standard]==0.30.0 +jinja2==3.1.4 diff --git a/sync.py b/sync.py index 0c36511..49e6f48 100644 --- a/sync.py +++ b/sync.py @@ -18,6 +18,8 @@ import recurring_ical_events import mysql.connector from mysql.connector import Error as MySQLError +from api.database import get_connection + logging.basicConfig( level=os.environ.get("LOG_LEVEL", "INFO"), format="%(asctime)s [%(levelname)s] %(message)s", @@ -78,17 +80,6 @@ def bootstrap_database(): root_conn.close() -def get_connection(): - return mysql.connector.connect( - host=DB_HOST, - port=DB_PORT, - user=DB_USER, - password=DB_PASSWORD, - database=DB_NAME, - autocommit=False, - ) - - def ensure_schema(conn): cur = conn.cursor() cur.execute(""" @@ -228,7 +219,7 @@ def run_sync_once(): occurrences = expand_events(ics_bytes, window_start, window_end) log.info("ICS geladen, %d Instanzen im Fenster gefunden", len(occurrences)) - conn = get_connection() + conn = get_connection(autocommit=False) try: ensure_schema(conn) cur = conn.cursor() From 3b1801634c80c544427ad025d0a2a7168c18a066 Mon Sep 17 00:00:00 2001 From: Stefan Koelle Date: Sat, 1 Aug 2026 16:20:32 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20Error=20Handling=20+=20Context=20Man?= =?UTF-8?q?ager=20f=C3=BCr=20DB-Verbindungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - try/finally für Connection/Cleanup in allen Endpunkten - fetch_events() Helper-Funktion reduziert Code-Duplikation - Exception Logging bei DB-Fehlern - HTTPException 500 bei Datenbankfehlern --- api/main.py | 132 +++++++++++++++++++++++----------------------------- 1 file changed, 58 insertions(+), 74 deletions(-) diff --git a/api/main.py b/api/main.py index 466abf6..d4e9382 100644 --- a/api/main.py +++ b/api/main.py @@ -1,17 +1,22 @@ +import logging from datetime import datetime from pathlib import Path -from fastapi import FastAPI, Query +from fastapi import FastAPI, HTTPException, Query from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from pydantic import BaseModel from api.database import get_connection +log = logging.getLogger("calendar-api") + app = FastAPI(title="Calendar Sync API") templates = Jinja2Templates(directory=Path(__file__).parent / "templates") +SELECT_COLUMNS = "id, summary, description, location, start_at, end_at, all_day, status" + class EventResponse(BaseModel): id: int @@ -43,6 +48,32 @@ def row_to_event(row) -> EventResponse: ) +def fetch_events(limit: int = 10, search: str | None = None) -> list[dict]: + conn = get_connection() + try: + cur = conn.cursor() + try: + if search: + cur.execute( + f"SELECT {SELECT_COLUMNS} FROM calendar_events " + "WHERE deleted = 0 AND start_at >= NOW() AND summary LIKE %s " + "ORDER BY start_at ASC LIMIT %s", + (f"%{search}%", limit), + ) + else: + cur.execute( + f"SELECT {SELECT_COLUMNS} FROM calendar_events " + "WHERE deleted = 0 AND start_at >= NOW() " + "ORDER BY start_at ASC LIMIT %s", + (limit,), + ) + return cur.fetchall() + finally: + cur.close() + finally: + conn.close() + + @app.get("/api/health") def health(): return {"status": "ok"} @@ -53,35 +84,11 @@ def get_events( limit: int = Query(default=10, ge=1, le=50), search: str | None = Query(default=None), ): - conn = get_connection() - cur = conn.cursor() - - if search: - cur.execute( - """ - SELECT id, summary, description, location, start_at, end_at, all_day, status - FROM calendar_events - WHERE deleted = 0 AND start_at >= NOW() AND summary LIKE %s - ORDER BY start_at ASC - LIMIT %s - """, - (f"%{search}%", limit), - ) - else: - cur.execute( - """ - SELECT id, summary, description, location, start_at, end_at, all_day, status - FROM calendar_events - WHERE deleted = 0 AND start_at >= NOW() - ORDER BY start_at ASC - LIMIT %s - """, - (limit,), - ) - - rows = cur.fetchall() - cur.close() - conn.close() + try: + rows = fetch_events(limit=limit, search=search) + except Exception: + log.exception("DB-Fehler bei /api/events") + raise HTTPException(status_code=500, detail="Database error") events = [row_to_event(row) for row in rows] @@ -95,23 +102,24 @@ def get_events( @app.get("/api/events/{event_id}", response_model=EventResponse) def get_event(event_id: int): conn = get_connection() - cur = conn.cursor() - - cur.execute( - """ - SELECT id, summary, description, location, start_at, end_at, all_day, status - FROM calendar_events - WHERE id = %s AND deleted = 0 - """, - (event_id,), - ) - - row = cur.fetchone() - cur.close() - conn.close() + try: + cur = conn.cursor() + try: + cur.execute( + f"SELECT {SELECT_COLUMNS} FROM calendar_events " + "WHERE id = %s AND deleted = 0", + (event_id,), + ) + row = cur.fetchone() + finally: + cur.close() + except Exception: + log.exception("DB-Fehler bei /api/events/%d", event_id) + raise HTTPException(status_code=500, detail="Database error") + finally: + conn.close() if not row: - from fastapi import HTTPException raise HTTPException(status_code=404, detail="Event not found") return row_to_event(row) @@ -122,35 +130,11 @@ def index( search: str | None = Query(default=None), limit: int = Query(default=10, ge=1, le=50), ): - conn = get_connection() - cur = conn.cursor() - - if search: - cur.execute( - """ - SELECT id, summary, description, location, start_at, end_at, all_day, status - FROM calendar_events - WHERE deleted = 0 AND start_at >= NOW() AND summary LIKE %s - ORDER BY start_at ASC - LIMIT %s - """, - (f"%{search}%", limit), - ) - else: - cur.execute( - """ - SELECT id, summary, description, location, start_at, end_at, all_day, status - FROM calendar_events - WHERE deleted = 0 AND start_at >= NOW() - ORDER BY start_at ASC - LIMIT %s - """, - (limit,), - ) - - rows = cur.fetchall() - cur.close() - conn.close() + try: + rows = fetch_events(limit=limit, search=search) + except Exception: + log.exception("DB-Fehler bei /") + raise HTTPException(status_code=500, detail="Database error") events = [] for row in rows: From 5b943681d6da223e86107d4b39851ae5443591fb Mon Sep 17 00:00:00 2001 From: Stefan Koelle Date: Sat, 1 Aug 2026 16:21:09 +0200 Subject: [PATCH 3/3] fix: API_PORT Umgebungsvariable entfernt --- .env.example | 3 --- docker-compose.yml | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.env.example b/.env.example index ba8a48a..a2089a7 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,3 @@ LOG_LEVEL=INFO # Optional: Healthchecks.io / Uptime Kuma URL (wird nach jedem Sync gepingt) # HEALTHCHECK_URL=https://hc-ping.com/DEINE_UUID - -# API Server -API_PORT=8000 diff --git a/docker-compose.yml b/docker-compose.yml index 598b5d7..0ebf1e6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,7 +33,7 @@ services: command: ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"] ports: - - "${API_PORT:-8000}:8000" + - "8000:8000" environment: - DB_HOST=${DB_HOST:-mariadb.fritz.box}