feat: Home Assistant Kalender-Integration + Bugfix ausgeblendete Kalender
- Neue Integration: Home Assistant als Kalenderquelle via REST-API
(GET /api/calendars + GET /api/calendars/{entity_id})
- Authentifizierung per Long-Lived Access Token
- Neues Modal zum Verbinden (Name, URL, Token) mit Fehlerbehandlung
- Kalender einzeln aktivierbar/deaktivierbar, Farbe änderbar
- Ausgeblendete HA-Kalender in Einstellungen wiederherstellbar
- Sync- und Trennen-Buttons in den Einstellungen
- Bugfix: CalDAV- und Google-Kalender mit sidebar_hidden=true
liefern nun keine Events mehr im Kalender
This commit is contained in:
@@ -12,7 +12,7 @@ from sqlalchemy import text
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from database import Base, engine
|
||||
from routers import auth_router, caldav_router, google_router, ical_router, local_router, profile_router, settings_router, users_router
|
||||
from routers import auth_router, caldav_router, google_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
@@ -76,6 +76,7 @@ app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"
|
||||
app.include_router(local_router.router, prefix="/api/local", tags=["local"])
|
||||
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
|
||||
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
|
||||
app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
|
||||
|
||||
FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
|
||||
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
||||
|
||||
@@ -30,6 +30,9 @@ class User(Base):
|
||||
google_accounts = relationship(
|
||||
"GoogleAccount", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
homeassistant_accounts = relationship(
|
||||
"HomeAssistantAccount", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class CalDAVAccount(Base):
|
||||
@@ -176,3 +179,32 @@ class GoogleCalendar(Base):
|
||||
sidebar_hidden = Column(Boolean, default=False)
|
||||
|
||||
account = relationship("GoogleAccount", back_populates="calendars")
|
||||
|
||||
|
||||
class HomeAssistantAccount(Base):
|
||||
__tablename__ = "homeassistant_accounts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
name = Column(String(100), nullable=False)
|
||||
url = Column(String(500), nullable=False)
|
||||
token = Column(Text, nullable=False)
|
||||
|
||||
user = relationship("User", back_populates="homeassistant_accounts")
|
||||
calendars = relationship(
|
||||
"HomeAssistantCalendar", back_populates="account", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class HomeAssistantCalendar(Base):
|
||||
__tablename__ = "homeassistant_calendars"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
account_id = Column(Integer, ForeignKey("homeassistant_accounts.id"), nullable=False)
|
||||
entity_id = Column(String(255), nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
color = Column(String(7), nullable=True)
|
||||
enabled = Column(Boolean, default=True)
|
||||
sidebar_hidden = Column(Boolean, default=False)
|
||||
|
||||
account = relationship("HomeAssistantAccount", back_populates="calendars")
|
||||
|
||||
@@ -266,7 +266,7 @@ def get_events(
|
||||
|
||||
for account in accounts:
|
||||
for calendar in account.calendars:
|
||||
if not calendar.enabled:
|
||||
if not calendar.enabled or calendar.sidebar_hidden:
|
||||
continue
|
||||
try:
|
||||
events = caldav_client.fetch_events(
|
||||
@@ -355,6 +355,19 @@ def get_events(
|
||||
logger.error("Error fetching Google Calendar for %s: %s", g_acc.email, exc)
|
||||
google_errors.append({"email": g_acc.email})
|
||||
|
||||
# ── Home Assistant events ─────────────────────────────
|
||||
from routers.homeassistant_router import get_ha_events
|
||||
ha_accounts = (
|
||||
db.query(models.HomeAssistantAccount)
|
||||
.filter(models.HomeAssistantAccount.user_id == current_user.id)
|
||||
.all()
|
||||
)
|
||||
for ha_acc in ha_accounts:
|
||||
try:
|
||||
all_events.extend(get_ha_events(ha_acc, start_dt, end_dt))
|
||||
except Exception as exc:
|
||||
logger.error("Error fetching HA events for %s: %s", ha_acc.name, exc)
|
||||
|
||||
return {"events": all_events, "errors": google_errors}
|
||||
|
||||
|
||||
|
||||
@@ -388,7 +388,7 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt:
|
||||
|
||||
all_events = []
|
||||
for gcal in account.calendars:
|
||||
if not gcal.enabled:
|
||||
if not gcal.enabled or gcal.sidebar_hidden:
|
||||
continue
|
||||
if _is_system_calendar(gcal.cal_id):
|
||||
continue
|
||||
|
||||
268
backend/routers/homeassistant_router.py
Normal file
268
backend/routers/homeassistant_router.py
Normal file
@@ -0,0 +1,268 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import requests as http_requests
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
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"
|
||||
|
||||
|
||||
# ── 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(401, "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 _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) -> list:
|
||||
all_events = []
|
||||
for cal in account.calendars:
|
||||
if not cal.enabled or cal.sidebar_hidden:
|
||||
continue
|
||||
try:
|
||||
raw = _ha_get_events(account.url, account.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,
|
||||
"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 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),
|
||||
):
|
||||
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,
|
||||
)
|
||||
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.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")
|
||||
|
||||
remote_cals = _ha_get_calendars(acc.url, acc.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}
|
||||
Reference in New Issue
Block a user