Files
2026-08-10 16:47:13 +02:00

198 lines
5.7 KiB
Python

# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
# Licensed under the MIT License. See LICENSE file in project root for details.
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
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")
SELECT_COLUMNS = "id, summary, description, location, start_at, end_at, all_day, status"
# Get timezone from environment, default to UTC
TIMEZONE_NAME = os.environ.get("TIMEZONE", "UTC")
try:
TIMEZONE = ZoneInfo(TIMEZONE_NAME)
except Exception:
log.warning(f"Invalid TIMEZONE '{TIMEZONE_NAME}', falling back to UTC")
TIMEZONE = ZoneInfo("UTC")
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)
def to_iso_local(dt: datetime) -> str:
"""Konvertiere naive UTC datetime zu ISO-String in lokaler Zeitzone."""
if dt is None:
return None
local_dt = to_local_timezone(dt)
return local_dt.isoformat()
class EventResponse(BaseModel):
id: int
summary: str | None
description: str | None
location: str | None
start_at: str
end_at: str | None
timezone: str
all_day: bool
status: str
class EventsListResponse(BaseModel):
events: list[EventResponse]
count: int
query_time: str
timezone: str
def row_to_event(row) -> EventResponse:
return EventResponse(
id=row[0],
summary=row[1],
description=row[2],
location=row[3],
start_at=to_iso_local(row[4]) if row[4] else None,
end_at=to_iso_local(row[5]) if row[5] else None,
timezone=TIMEZONE_NAME,
all_day=bool(row[6]),
status=row[7],
)
def fetch_events(limit: int = 10, search: str | None = None, calendar_label: str | None = None) -> list[dict]:
conn = get_connection()
try:
cur = conn.cursor()
try:
conditions = [f"deleted = 0", f"start_at >= {NOW_SQL}"]
params: list = []
if search:
conditions.append(f"summary LIKE {PH}")
params.append(f"%{search}%")
if calendar_label:
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 {PH}",
tuple(params),
)
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),
calendar_label: str | None = Query(default=None),
):
try:
rows = fetch_events(limit=limit, search=search, calendar_label=calendar_label)
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.now(timezone.utc).replace(tzinfo=None).isoformat() + "Z",
timezone=TIMEZONE_NAME, # <--- Add this line
)
@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 "
f"WHERE id = {PH} 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(
request: Request,
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": to_iso_local(row[4]) if row[4] else None,
"end_at": to_iso_local(row[5]) if row[5] else None,
"timezone": TIMEZONE_NAME,
"all_day": bool(row[6]),
"status": row[7],
})
return templates.TemplateResponse(
request,
"index.html",
{"events": events, "search": search or ""},
)