Merge pull request #2 from skoelle/feature/rest-api-webfrontend

feat: REST API + Web-Frontend
This commit is contained in:
Stefan Koelle
2026-08-01 16:25:10 +02:00
committed by GitHub
10 changed files with 821 additions and 12 deletions
+1
View File
@@ -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
+133
View File
@@ -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 `<style>` Block
- Suchfeld oben (Formular mit GET Parameter `search`)
- Darstellung:
- Datum + Uhrzeit (oder "Ganztägig")
- Titel (summary)
- Ort (location) - falls vorhanden
- Status Badge (grün=CONFIRMED, gelb=TENTATIVE, rot=CANCELLED)
- Kein JavaScript nötig (nur server-side rendering)
**Dateien:** `api/templates/index.html`
---
## Phase 4: Dependencies + Docker
### Step 4.1: requirements.txt erweitern
```
fastapi==0.115.0
uvicorn[standard]==0.30.0
jinja2==3.1.4
```
### Step 4.2: Dockerfile anpassen
- `COPY api/ ./api/` hinzufügen
- Standard CMD bleibt `python sync.py`
### Step 4.3: docker-compose.yml erweitern
- `calendar-api` Service hinzufügen
- Gleicher Image
- `command: ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]`
- Port Mapping: `${API_PORT:-8000}:8000`
- DB Environment Variablen
- Watchtower Label
### Step 4.4: .env.example erweitern
- `API_PORT=8000` hinzufügen
**Dateien:** `requirements.txt`, `Dockerfile`, `docker-compose.yml`, `.env.example`
---
## Phase 5: Refactoring sync.py
### Step 5.1: sync.py importieren
- `from api.database import get_connection` verwenden
- Lokale `get_connection()` Funktion entfernen
- Database Bootstrap bleibt in sync.py (gehört nicht zur API)
---
## Zusammenfassung der zu erstellenden Dateien
| Datei | Aktion |
|-------------------------|-----------------|
| `api/__init__.py` | Neu erstellen |
| `api/database.py` | Neu erstellen |
| `api/main.py` | Neu erstellen |
| `api/templates/index.html` | Neu erstellen |
| `sync.py` | Import anpassen |
| `requirements.txt` | Erweitern |
| `Dockerfile` | Erweitern |
| `docker-compose.yml` | Erweitern |
| `.env.example` | Erweitern |
---
## Offene Punkte
- [x] DB Bootstrap - bleibt in sync.py
- [x] Template Styling - einfaches CSS, kein Framework
- [x] Search - optionaler Suchbegriff auf Event-Titel (API + Frontend)
+272
View File
@@ -0,0 +1,272 @@
# SPEC.md - Calendar Sync Projekt
## 1. Projektübersicht
Python-basiertes System zur Synchronisation eines Google Calendar ICS-Feeds nach MariaDB, mit zusätzlichem REST API + Web-Frontend für die Anzeige der nächsten Termine. Läuft als Docker Container im Homelab.
## 2. Architektur
```
┌─────────────────────┐ ┌─────────────────────┐
│ calendar-sync │ │ calendar-api │
│ (Sync Tool) │ │ (FastAPI + HTML) │
│ │ │ │
│ - ICS Polling │ │ - REST API │
│ - RRULE Expansion │ │ - Web-Frontend │
│ - MariaDB Write │ │ - MariaDB Read │
└──────────┬──────────┘ └──────────┬──────────┘
│ │
└────────────┬───────────────┘
┌────────▼────────┐
│ MariaDB │
│ calendar_sync │
└─────────────────┘
```
**Entscheidung:** Gleicher Docker Build (ein Dockerfile), zwei verschiedene Container/Services via `docker-compose.yml`. Das Image wird mit einem `--entrypoint` Parameter gesteuert.
## 3. Bestehendes System (Sync Tool)
### 3.1 Funktionen
- Lädt periodisch einen ICS-Feed von Google Calendar
- Expandiert wiederkehrende Events (RRULE/EXDATE/RECURRENCE-ID)
- Schreibt Einzel-Instanzen in MariaDB (calendar_events Tabelle)
- Soft-Delete: Events werden mit `deleted=1` markiert statt gelöscht
- Optionaler Healthcheck-Ping nach jedem Sync-Durchlauf
- Optionale Datenbank-Bootstrap (DB + User anlegen)
### 3.2 Datenbank-Schema (calendar_events)
| Feld | Typ | Beschreibung |
|-------------------|----------------------|----------------------------------|
| id | BIGINT AUTO_INCREMENT| Primärschlüssel |
| calendar_label | VARCHAR(64) | Kalender-Bezeichnung |
| instance_key | VARCHAR(255) | SHA1 Hash (UID + RECURRENCE-ID) |
| uid | VARCHAR(255) | Originale Event UID |
| recurrence_id | VARCHAR(64) | RECURRENCE-ID (nullable) |
| summary | VARCHAR(512) | Titel des Events |
| description | TEXT | Beschreibung |
| location | VARCHAR(512) | Ort |
| start_at | DATETIME | Startzeit (naive UTC) |
| end_at | DATETIME | Endzeit (naive UTC, nullable) |
| all_day | TINYINT(1) | Ganzägiges Event |
| status | VARCHAR(32) | CONFIRMED/CANCELLED/TENTATIVE |
| deleted | TINYINT(1) | Soft-Delete Flag |
| last_seen_at | DATETIME | Letzter Sync-Zeitpunkt |
| created_at | DATETIME | Erstellungszeitpunkt |
| updated_at | DATETIME | Letzter Update-Zeitpunkt |
### 3.3 Environment Variablen
- `ICS_URL` (required) - Google Calendar ICS Feed URL
- `CALENDAR_LABEL` - Bezeichnung für den Kalender
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` - MariaDB Zugangsdaten
- `SYNC_INTERVAL_MINUTES` - Sync Intervall (default: 15)
- `WINDOW_PAST_DAYS`, `WINDOW_FUTURE_DAYS` - Zeitfenster für Events
- `LOG_LEVEL` - Logging Level
- `HEALTHCHECK_URL` - Optionaler Healthcheck Endpoint
- `DB_BOOTSTRAP`, `DB_ROOT_USER`, `DB_ROOT_PASSWORD` - Optionales DB Bootstrap
## 4. Neues System (API + Web-Frontend)
### 4.1 REST API Endpoints
#### GET /api/events
Gibt die nächsten N Termine zurück.
**Query Parameter:**
- `limit` (optional, default: 10, max: 50) - Anzahl der Events
- `calendar_label` (optional) - Filter nach Kalender
- `search` (optional) - Suchbegriff für Event-Titel (LIKE %search%)
**Response (JSON):**
```json
{
"events": [
{
"id": 123,
"summary": "Meeting mit Team",
"description": "...",
"location": "Raum 101",
"start_at": "2025-01-15T10:00:00",
"end_at": "2025-01-15T11:00:00",
"all_day": false,
"status": "CONFIRMED"
}
],
"count": 10,
"query_time": "2025-01-14T14:30:00Z"
}
```
#### GET /api/events/{id}
Gibt ein einzelnes Event zurück.
#### GET /api/health
Healthcheck Endpoint für den API Container.
### 4.2 Web-Frontend
**URL:** `http://localhost:8000/` (oder konfigurierbarer Port)
**Funktionen:**
- Zeigt die nächsten 10 Termine in einer übersichtlichen Liste
- Suchfeld oben (optional, filtert nach Event-Titel)
- Responsive Design (funktioniert auf Desktop und Handy)
- Einfaches, cleanes Design ohne Framework (nur HTML + CSS + vanilla JS)
**Darstellung pro Event:**
- Datum + Uhrzeit (oder "Ganztägig")
- Titel (summary)
- Ort (location) - falls vorhanden
- Status-Anzeige (Farbcode: grün=CONFIRMED, gelb=TENTATIVE, rot=CANCELLED)
### 4.3 Technologie-Stack (API)
- **Framework:** FastAPI
- **Templating:** Jinja2 (server-side rendering)
- **DB-Zugriff:** mysql-connector-python (gleicher Connection-Pool wie Sync)
- **Port:** 8000 (konfigurierbar via `API_PORT`)
### 4.4 Additional Environment Variablen (API)
- `API_PORT` - Port für den API Server (default: 8000)
- `API_HOST` - Bind Address (default: 0.0.0.0)
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` - Identisch zum Sync
## 5. Docker Setup
### 5.1 Dockerfile (erweitert)
```dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY sync.py .
COPY api/ ./api/
ENV PYTHONUNBUFFERED=1
# Default: Sync Tool
CMD ["python", "sync.py"]
```
Das Image enthält sowohl das Sync-Tool als auch die API. Der jeweilige Service wird via `docker-compose.yml` gesteuert.
### 5.2 Docker Compose (erweitert)
```yaml
services:
calendar-sync:
image: ghcr.io/skoelle/calender_sync:latest
container_name: calendar-sync
restart: unless-stopped
command: ["python", "sync.py"]
environment:
- ICS_URL=${ICS_URL}
- CALENDAR_LABEL=${CALENDAR_LABEL:-privat}
- 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}
- SYNC_INTERVAL_MINUTES=${SYNC_INTERVAL_MINUTES:-15}
- WINDOW_PAST_DAYS=${WINDOW_PAST_DAYS:-90}
- WINDOW_FUTURE_DAYS=${WINDOW_FUTURE_DAYS:-365}
- 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"
networks:
- docker-backend
networks:
docker-backend:
external: true
```
## 6. Projektstruktur (Zielstruktur)
```
.
├── sync.py # Hauptskript Sync Tool
├── api/
│ ├── __init__.py
│ ├── main.py # FastAPI App + Routes
│ ├── database.py # DB Connection Pool (shared)
│ └── templates/
│ └── index.html # Jinja2 Template für Web-Frontend
├── requirements.txt # Python Dependencies (erweitert)
├── Dockerfile # Docker Image Definition (erweitert)
├── docker-compose.yml # Docker Compose Konfiguration (erweitert)
├── mariadb-setup.sql # Manuelles DB-Setup Script
├── .env.example # Beispiel-Umgebungsvariablen (erweitert)
├── SPEC.md # Diese Spezifikation
└── .github/workflows/ # CI/CD (Docker Build + Push)
```
## 7. Dependencies (requirements.txt)
```
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
```
## 8. Design-Entscheidungen
| Thema | Entscheidung |
|------------------------|-------------------------------------------|
| API Authentifizierung | Keine (nur Homelab intern) |
| Caching | Kein (SQL Query bei jedem Request) |
| Auto-Refresh Frontend | Kein (manueller Reload) |
| CORS | Kein (Same-Origin via Jinja2 Templates) |
| Multi-Sync | Nicht benötigt |
| iCal Export | Nicht benötigt |
| Benachrichtigungen | Nicht benötigt |
| Dark Mode | Nicht benötigt |
| Search | Optionaler Suchbegriff auf Event-Titel |
| DB Bootstrap | Bleibt in sync.py |
| Template Styling | Einfaches CSS, kein Framework |
| Logging | Nur Errors + Request Log auf INFO Level |
## 9. Testing
### 9.1 Unit Tests (optional, später)
- `test_to_naive_utc()` - Zeitkonvertierung
- `test_instance_key_for()` - Key Generation
- API Endpoint Tests mit `httpx` + `pytest`
### 9.2 Integration Tests (optional, später)
- Sync Tool → DB → API → Response validieren
## 10. CI/CD
Bestehender GitHub Actions Workflow erweitern:
- Build einmal für beide Services
- Optional: Separater Tag für API-only Image
## 11. Future Enhancements (nicht im Scope)
- [ ] Kalender-Filter UI (nach calendar_label)
- [ ] Suchfunktion nach Event-Titel
View File
+19
View File
@@ -0,0 +1,19 @@
import os
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,
)
+155
View File
@@ -0,0 +1,155 @@
import logging
from datetime import datetime
from pathlib import Path
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
summary: str | None
description: str | None
location: str | None
start_at: str
end_at: str | None
all_day: bool
status: str
class EventsListResponse(BaseModel):
events: list[EventResponse]
count: int
query_time: str
def row_to_event(row) -> EventResponse:
return EventResponse(
id=row[0],
summary=row[1],
description=row[2],
location=row[3],
start_at=row[4].isoformat() if row[4] else None,
end_at=row[5].isoformat() if row[5] else None,
all_day=bool(row[6]),
status=row[7],
)
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"}
@app.get("/api/events", response_model=EventsListResponse)
def get_events(
limit: int = Query(default=10, ge=1, le=50),
search: str | None = Query(default=None),
):
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]
return EventsListResponse(
events=events,
count=len(events),
query_time=datetime.utcnow().isoformat() + "Z",
)
@app.get("/api/events/{event_id}", response_model=EventResponse)
def get_event(event_id: int):
conn = get_connection()
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:
raise HTTPException(status_code=404, detail="Event not found")
return row_to_event(row)
@app.get("/", response_class=HTMLResponse)
def index(
search: str | None = Query(default=None),
limit: int = Query(default=10, ge=1, le=50),
):
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:
events.append({
"id": row[0],
"summary": row[1],
"description": row[2],
"location": row[3],
"start_at": row[4],
"end_at": row[5],
"all_day": bool(row[6]),
"status": row[7],
})
return templates.TemplateResponse(
"index.html",
{"request": {}, "events": events, "search": search or ""},
)
+214
View File
@@ -0,0 +1,214 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calendar Sync</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
h1 {
font-size: 1.5rem;
margin-bottom: 1.5rem;
color: #222;
}
.search-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.search-form input[type="text"] {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
background: #fff;
}
.search-form input[type="text"]:focus {
outline: none;
border-color: #0066cc;
}
.search-form button {
padding: 0.75rem 1.5rem;
background: #0066cc;
color: #fff;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
}
.search-form button:hover {
background: #0055aa;
}
.search-form a {
padding: 0.75rem 1rem;
background: #e0e0e0;
color: #333;
text-decoration: none;
border-radius: 6px;
font-size: 1rem;
}
.search-form a:hover {
background: #d0d0d0;
}
.events-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.event-card {
background: #fff;
border-radius: 8px;
padding: 1rem 1.25rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.event-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 0.5rem;
}
.event-date {
font-size: 0.875rem;
color: #666;
}
.event-summary {
font-size: 1.125rem;
font-weight: 600;
color: #222;
}
.event-location {
font-size: 0.875rem;
color: #888;
margin-top: 0.25rem;
}
.event-location::before {
content: "\1F4CD ";
}
.badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
}
.badge-confirmed {
background: #d4edda;
color: #155724;
}
.badge-tentative {
background: #fff3cd;
color: #856404;
}
.badge-cancelled {
background: #f8d7da;
color: #721c24;
}
.no-events {
text-align: center;
color: #888;
padding: 3rem 1rem;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.all-day {
font-size: 0.8rem;
color: #0066cc;
font-weight: 500;
}
</style>
</head>
<body>
<div class="container">
<h1>Termine</h1>
<form class="search-form" method="get" action="/">
<input type="text" name="search" value="{{ search }}" placeholder="Suche nach Titel...">
<button type="submit">Suchen</button>
{% if search %}
<a href="/">Zurücksetzen</a>
{% endif %}
</form>
<div class="events-list">
{% if events %}
{% for event in events %}
<div class="event-card">
<div class="event-header">
<div>
<div class="event-date">
{% if event.all_day %}
{{ event.start_at.strftime('%d.%m.%Y') }} <span class="all-day">Ganztägig</span>
{% 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 %}
</div>
<div class="event-summary">{{ event.summary or '(Kein Titel)' }}</div>
{% if event.location %}
<div class="event-location">{{ event.location }}</div>
{% endif %}
</div>
<span class="badge badge-{{ event.status|lower }}">
{{ event.status }}
</span>
</div>
</div>
{% endfor %}
{% else %}
<div class="no-events">
{% if search %}
Keine Termine für "{{ search }}" gefunden.
{% else %}
Keine anstehenden Termine.
{% endif %}
</div>
{% endif %}
</div>
</div>
</body>
</html>
+21
View File
@@ -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:
- "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"
+3
View File
@@ -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
+3 -12
View File
@@ -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()