import logging import secrets import time from datetime import datetime, timezone from typing import Optional from urllib.parse import urlencode import requests as http_requests from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import RedirectResponse from pydantic import BaseModel from sqlalchemy.orm import Session import models from auth import get_current_user from database import get_db logger = logging.getLogger(__name__) router = APIRouter() HA_DEFAULT_COLOR = "#03a9f4" # In-memory store for pending OAuth states (short-lived, ~10 min TTL) _pending_oauth: dict[str, dict] = {} def _cleanup_pending(): now = time.time() for k in [k for k, v in _pending_oauth.items() if v["expires"] < now]: _pending_oauth.pop(k, None) # ── Auth helpers ────────────────────────────────────────── def _ha_refresh(url: str, refresh_token: str, client_id: str) -> tuple: """Refresh grant → (access_token, expires_in)""" resp = http_requests.post( f"{url.rstrip('/')}/auth/token", data={ "grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": client_id, }, timeout=10, verify=False, ) resp.raise_for_status() data = resp.json() return data["access_token"], data.get("expires_in", 1800) def _get_valid_token(account: models.HomeAssistantAccount, db: Session) -> str: """Return a valid access token, refreshing if necessary.""" if account.auth_method != "oauth": return account.token # Long-Lived Token läuft nicht ab now = datetime.now(timezone.utc) if account.token_expiry and account.token_expiry.replace(tzinfo=timezone.utc) > now: return account.token # Needs refresh try: access_token, expires_in = _ha_refresh( account.url, account.refresh_token, account.client_id or "" ) except Exception as exc: logger.error("HA token refresh failed for %s: %s", account.name, exc) raise HTTPException(401, "Home Assistant Token abgelaufen, bitte Konto neu verbinden") account.token = access_token account.token_expiry = datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc) db.commit() return access_token # ── HA API helpers ──────────────────────────────────────── def _ha_get_calendars(url: str, token: str) -> list: try: resp = http_requests.get( f"{url.rstrip('/')}/api/calendars", headers={"Authorization": f"Bearer {token}"}, timeout=10, verify=False, ) resp.raise_for_status() return resp.json() except http_requests.exceptions.ConnectionError: raise HTTPException(503, "Home Assistant nicht erreichbar") except http_requests.exceptions.Timeout: raise HTTPException(503, "Home Assistant antwortet nicht (Timeout)") except http_requests.exceptions.HTTPError as e: if e.response is not None and e.response.status_code == 401: raise HTTPException(400, "Ungültiger Access Token") raise HTTPException(502, f"Home Assistant Fehler: {e}") def _ha_get_events(url: str, token: str, entity_id: str, start_dt: datetime, end_dt: datetime) -> list: try: resp = http_requests.get( f"{url.rstrip('/')}/api/calendars/{entity_id}", headers={"Authorization": f"Bearer {token}"}, params={"start": start_dt.isoformat(), "end": end_dt.isoformat()}, timeout=15, verify=False, ) resp.raise_for_status() return resp.json() except http_requests.exceptions.ConnectionError: raise http_requests.exceptions.ConnectionError(f"HA nicht erreichbar für {entity_id}") except http_requests.exceptions.Timeout: raise http_requests.exceptions.Timeout(f"HA Timeout für {entity_id}") def _ha_update_event(url: str, token: str, entity_id: str, uid: str, data: dict): """Update an event via HA service call API (calendar.update_event).""" base = url.rstrip("/") headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} body = {"entity_id": entity_id, "uid": uid} if "title" in data: body["summary"] = data["title"] if "description" in data: body["description"] = data["description"] if "location" in data: body["location"] = data["location"] if "start" in data and "end" in data: if data.get("allDay"): body["dtstart"] = data["start"][:10] body["dtend"] = data["end"][:10] else: s = data["start"].replace("Z", "").replace("T", " ") e = data["end"].replace("Z", "").replace("T", " ") # Strip timezone offset if present (e.g. +02:00) if "+" in s and s.index("+") > 10: s = s[:s.index("+")] if "+" in e and e.index("+") > 10: e = e[:e.index("+")] body["dtstart"] = s body["dtend"] = e resp = http_requests.post( f"{base}/api/services/calendar/update_event", headers=headers, json=body, timeout=15, verify=False, ) resp.raise_for_status() return resp def _ha_delete_event(url: str, token: str, entity_id: str, uid: str): """Delete an event via HA service call API (calendar.delete_event).""" base = url.rstrip("/") headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} resp = http_requests.post( f"{base}/api/services/calendar/delete_event", headers=headers, json={"entity_id": entity_id, "uid": uid}, timeout=15, verify=False, ) resp.raise_for_status() return resp def _parse_ha_event(ev: dict, cal_db_id: int, cal_name: str, cal_color: str) -> dict: start = ev.get("start", {}) end = ev.get("end", {}) all_day = "date" in start and "dateTime" not in start return { "id": ev.get("uid") or f"ha-{cal_db_id}-{ev.get('summary', '')}", "url": f"homeassistant://{cal_db_id}/{ev.get('uid', '')}", "title": ev.get("summary", "(Kein Titel)"), "start": start.get("dateTime") or start.get("date", ""), "end": end.get("dateTime") or end.get("date", ""), "allDay": all_day, "location": ev.get("location", ""), "description": ev.get("description", ""), "color": None, "calendar_id": f"homeassistant-{cal_db_id}", "calendar_name": cal_name, "calendarColor": cal_color, "source": "homeassistant", } def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime, db: Session) -> list: all_events = [] try: token = _get_valid_token(account, db) except Exception as exc: logger.error("HA token error for %s: %s", account.name, exc) raise for cal in account.calendars: if not cal.enabled or cal.sidebar_hidden: continue try: raw = _ha_get_events(account.url, token, cal.entity_id, start_dt, end_dt) color = cal.color or HA_DEFAULT_COLOR for ev in raw: all_events.append(_parse_ha_event(ev, cal.id, cal.name, color)) except Exception as exc: logger.error("HA event fetch error %s (%s): %s", cal.entity_id, account.name, exc) return all_events # ── Serialization ───────────────────────────────────────── def _account_dict(a: models.HomeAssistantAccount) -> dict: return { "id": a.id, "name": a.name, "url": a.url, "auth_method": a.auth_method or "token", "calendars": [ { "id": c.id, "name": c.name, "entity_id": c.entity_id, "color": c.color or HA_DEFAULT_COLOR, "enabled": c.enabled, "sidebar_hidden": bool(c.sidebar_hidden), } for c in a.calendars ], } # ── Pydantic models ─────────────────────────────────────── class HAAccountCreate(BaseModel): name: str url: str token: str class HAOAuthStart(BaseModel): name: str url: str client_id: str redirect_uri: str class HACalendarUpdate(BaseModel): enabled: Optional[bool] = None color: Optional[str] = None name: Optional[str] = None sidebar_hidden: Optional[bool] = None # ── Endpoints ───────────────────────────────────────────── @router.get("/accounts") def list_accounts( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): accounts = ( db.query(models.HomeAssistantAccount) .filter(models.HomeAssistantAccount.user_id == current_user.id) .all() ) return [_account_dict(a) for a in accounts] @router.post("/accounts") def add_account( data: HAAccountCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): """Create a HA account from a Long-Lived Access Token.""" remote_cals = _ha_get_calendars(data.url, data.token) account = models.HomeAssistantAccount( user_id=current_user.id, name=data.name, url=data.url, token=data.token, auth_method="token", refresh_token=None, token_expiry=None, ) db.add(account) db.flush() for cal in remote_cals: entity_id = cal.get("entity_id", "") if not entity_id: continue db.add(models.HomeAssistantCalendar( account_id=account.id, entity_id=entity_id, name=cal.get("name") or entity_id, color=None, enabled=True, )) db.commit() db.refresh(account) return _account_dict(account) @router.post("/auth-url") def oauth_start( data: HAOAuthStart, current_user: models.User = Depends(get_current_user), ): """Start the OAuth flow: store pending state, return HA authorization URL.""" _cleanup_pending() state_token = secrets.token_urlsafe(32) _pending_oauth[state_token] = { "user_id": current_user.id, "ha_url": data.url.rstrip('/'), "name": data.name, "client_id": data.client_id, "redirect_uri": data.redirect_uri, "expires": time.time() + 600, } params = { "client_id": data.client_id, "redirect_uri": data.redirect_uri, "state": state_token, "response_type": "code", } return {"url": f"{data.url.rstrip('/')}/auth/authorize?{urlencode(params)}"} @router.get("/callback") def oauth_callback( request: Request, code: str = Query(""), state: str = Query(""), error: str = Query(""), db: Session = Depends(get_db), ): """Callback from Home Assistant after user authorization.""" if error or not code: return RedirectResponse(url=f"/?ha_error={error or 'no_code'}", status_code=302) pending = _pending_oauth.pop(state, None) if not pending or pending["expires"] < time.time(): return RedirectResponse(url="/?ha_error=state_expired", status_code=302) ha_url = pending["ha_url"] client_id = pending["client_id"] # Exchange code for tokens try: resp = http_requests.post( f"{ha_url}/auth/token", data={ "grant_type": "authorization_code", "code": code, "client_id": client_id, }, timeout=15, verify=False, ) except Exception as exc: logger.error("HA token exchange connection error: %s", exc) return RedirectResponse(url="/?ha_error=ha_unreachable", status_code=302) if resp.status_code != 200: logger.error("HA token exchange failed (%s): %s", resp.status_code, resp.text) return RedirectResponse(url="/?ha_error=token_exchange_failed", status_code=302) tokens = resp.json() access_token = tokens["access_token"] refresh_token = tokens.get("refresh_token", "") expires_in = tokens.get("expires_in", 1800) now = datetime.now(timezone.utc) try: remote_cals = _ha_get_calendars(ha_url, access_token) except HTTPException as exc: logger.error("HA calendar fetch failed after OAuth: %s", exc.detail) return RedirectResponse(url="/?ha_error=calendars_failed", status_code=302) account = models.HomeAssistantAccount( user_id=pending["user_id"], name=pending["name"], url=ha_url, token=access_token, auth_method="oauth", refresh_token=refresh_token, token_expiry=datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc), client_id=client_id, ) db.add(account) db.flush() for cal in remote_cals: entity_id = cal.get("entity_id", "") if not entity_id: continue db.add(models.HomeAssistantCalendar( account_id=account.id, entity_id=entity_id, name=cal.get("name") or entity_id, color=None, enabled=True, )) db.commit() return RedirectResponse(url="/?ha_connected=1", status_code=302) @router.delete("/accounts/{account_id}") def delete_account( account_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): acc = ( db.query(models.HomeAssistantAccount) .filter( models.HomeAssistantAccount.id == account_id, models.HomeAssistantAccount.user_id == current_user.id, ) .first() ) if not acc: raise HTTPException(404, "Account not found") db.delete(acc) db.commit() return {"ok": True} @router.post("/accounts/{account_id}/sync") def sync_account( account_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): acc = ( db.query(models.HomeAssistantAccount) .filter( models.HomeAssistantAccount.id == account_id, models.HomeAssistantAccount.user_id == current_user.id, ) .first() ) if not acc: raise HTTPException(404, "Account not found") token = _get_valid_token(acc, db) remote_cals = _ha_get_calendars(acc.url, token) existing = {c.entity_id: c for c in acc.calendars} for cal in remote_cals: entity_id = cal.get("entity_id", "") if not entity_id: continue if entity_id not in existing: db.add(models.HomeAssistantCalendar( account_id=acc.id, entity_id=entity_id, name=cal.get("name") or entity_id, color=None, enabled=True, )) else: existing[entity_id].name = cal.get("name") or entity_id db.commit() db.refresh(acc) return _account_dict(acc) @router.put("/calendars/{calendar_id}") def update_calendar( calendar_id: int, data: HACalendarUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): cal = ( db.query(models.HomeAssistantCalendar) .join(models.HomeAssistantAccount) .filter( models.HomeAssistantCalendar.id == calendar_id, models.HomeAssistantAccount.user_id == current_user.id, ) .first() ) if not cal: raise HTTPException(404, "Calendar not found") if data.enabled is not None: cal.enabled = data.enabled if data.color is not None: cal.color = data.color if data.name is not None: cal.name = data.name if data.sidebar_hidden is not None: cal.sidebar_hidden = data.sidebar_hidden db.commit() return {"ok": True} # ── Event CRUD ─────────────────────────────────────────── class HAEventUpdate(BaseModel): title: Optional[str] = None start: Optional[str] = None end: Optional[str] = None allDay: Optional[bool] = None location: Optional[str] = None description: Optional[str] = None @router.put("/events/{calendar_id}/{uid}") def update_event( calendar_id: int, uid: str, data: HAEventUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): cal = ( db.query(models.HomeAssistantCalendar) .join(models.HomeAssistantAccount) .filter( models.HomeAssistantCalendar.id == calendar_id, models.HomeAssistantAccount.user_id == current_user.id, ) .first() ) if not cal: raise HTTPException(404, "Calendar not found") account = cal.account token = _get_valid_token(account, db) try: _ha_update_event( account.url, token, cal.entity_id, uid, data.model_dump(exclude_none=True), ) return {"ok": True} except Exception as exc: raise HTTPException(500, f"HA event update failed: {exc}") @router.delete("/events/{calendar_id}/{uid}") def delete_event( calendar_id: int, uid: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): cal = ( db.query(models.HomeAssistantCalendar) .join(models.HomeAssistantAccount) .filter( models.HomeAssistantCalendar.id == calendar_id, models.HomeAssistantAccount.user_id == current_user.id, ) .first() ) if not cal: raise HTTPException(404, "Calendar not found") account = cal.account token = _get_valid_token(account, db) try: _ha_delete_event(account.url, token, cal.entity_id, uid) return {"ok": True} except Exception as exc: raise HTTPException(500, f"HA event delete failed: {exc}")