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 caldav_client import _parse_ics from database import get_db logger = logging.getLogger(__name__) router = APIRouter() class SubscriptionCreate(BaseModel): name: str url: str color: str = "#46bdc6" refresh_minutes: int = 60 class SubscriptionUpdate(BaseModel): name: Optional[str] = None url: Optional[str] = None color: Optional[str] = None enabled: Optional[bool] = None refresh_minutes: Optional[int] = None def _sub_dict(sub: models.ICalSubscription) -> dict: return { "id": sub.id, "name": sub.name, "url": sub.url, "color": sub.color, "enabled": sub.enabled, "refresh_minutes": sub.refresh_minutes, "last_fetched": sub.last_fetched.isoformat() if sub.last_fetched else None, } def _fetch_ics(url: str) -> str: """Download .ics content from a URL.""" try: resp = http_requests.get(url, timeout=30, allow_redirects=True) resp.raise_for_status() return resp.text except http_requests.RequestException as e: raise ValueError(f"Fehler beim Abrufen der URL: {e}") def _refresh_if_needed(sub: models.ICalSubscription, db: Session, force: bool = False): """Refresh cached ICS data if stale or forced.""" now = datetime.now(timezone.utc) if ( not force and sub.cached_ics and sub.last_fetched and (now - sub.last_fetched.replace(tzinfo=timezone.utc)).total_seconds() < sub.refresh_minutes * 60 ): return # Cache still fresh try: sub.cached_ics = _fetch_ics(sub.url) sub.last_fetched = now db.commit() except ValueError: logger.warning("Could not refresh iCal subscription %s", sub.id) if not sub.cached_ics: raise def get_events_for_subscription( sub: models.ICalSubscription, start_dt: datetime, end_dt: datetime, db: Session = None ) -> list: """Parse cached ICS and filter events within the date range, applying local overrides.""" if not sub.cached_ics: return [] # Load overrides for this subscription overrides = {} if db: for ov in ( db.query(models.ICalOverride) .filter(models.ICalOverride.subscription_id == sub.id) .all() ): overrides[ov.event_uid] = ov all_events = _parse_ics(sub.cached_ics, f"ical://{sub.id}") filtered = [] for ev in all_events: uid = ev["id"] # Skip hidden (deleted) events ov = overrides.get(uid) if ov and ov.hidden: continue # Apply overrides if ov: if ov.title is not None: ev["title"] = ov.title if ov.start is not None: ev["start"] = ov.start if ov.end is not None: ev["end"] = ov.end if ov.all_day is not None: ev["allDay"] = ov.all_day if ov.location is not None: ev["location"] = ov.location if ov.description is not None: ev["description"] = ov.description if ov.color is not None: ev["color"] = ov.color try: ev_start = ev["start"] ev_end = ev["end"] if ev["allDay"]: if ev_end > start_dt.strftime("%Y-%m-%d") and ev_start < end_dt.strftime("%Y-%m-%d"): filtered.append(ev) else: ev_s = datetime.fromisoformat(ev_start.replace("Z", "+00:00")) ev_e = datetime.fromisoformat(ev_end.replace("Z", "+00:00")) if ev_s.tzinfo is None: ev_s = ev_s.replace(tzinfo=timezone.utc) if ev_e.tzinfo is None: ev_e = ev_e.replace(tzinfo=timezone.utc) if ev_e > start_dt and ev_s < end_dt: filtered.append(ev) except Exception: filtered.append(ev) for ev in filtered: ev["calendar_id"] = f"ical-{sub.id}" ev["calendar_name"] = sub.name ev["calendarColor"] = sub.color ev["source"] = "ical" return filtered # ── Subscription CRUD ───────────────────────────────────── @router.get("/subscriptions") def list_subscriptions( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): subs = ( db.query(models.ICalSubscription) .filter(models.ICalSubscription.user_id == current_user.id) .all() ) return [_sub_dict(s) for s in subs] @router.post("/subscriptions") def create_subscription( data: SubscriptionCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): # Validate URL by fetching it try: ics_content = _fetch_ics(data.url) except ValueError as e: raise HTTPException(400, str(e)) sub = models.ICalSubscription( user_id=current_user.id, name=data.name, url=data.url, color=data.color, refresh_minutes=data.refresh_minutes, cached_ics=ics_content, last_fetched=datetime.now(timezone.utc), ) db.add(sub) db.commit() db.refresh(sub) return _sub_dict(sub) @router.put("/subscriptions/{sub_id}") def update_subscription( sub_id: int, data: SubscriptionUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): sub = ( db.query(models.ICalSubscription) .filter( models.ICalSubscription.id == sub_id, models.ICalSubscription.user_id == current_user.id, ) .first() ) if not sub: raise HTTPException(404, "Subscription not found") if data.name is not None: sub.name = data.name if data.url is not None: sub.url = data.url if data.color is not None: sub.color = data.color if data.enabled is not None: sub.enabled = data.enabled if data.refresh_minutes is not None: sub.refresh_minutes = data.refresh_minutes db.commit() return {"ok": True} @router.delete("/subscriptions/{sub_id}") def delete_subscription( sub_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): sub = ( db.query(models.ICalSubscription) .filter( models.ICalSubscription.id == sub_id, models.ICalSubscription.user_id == current_user.id, ) .first() ) if not sub: raise HTTPException(404, "Subscription not found") db.delete(sub) db.commit() return {"ok": True} @router.post("/subscriptions/{sub_id}/refresh") def refresh_subscription( sub_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): sub = ( db.query(models.ICalSubscription) .filter( models.ICalSubscription.id == sub_id, models.ICalSubscription.user_id == current_user.id, ) .first() ) if not sub: raise HTTPException(404, "Subscription not found") try: _refresh_if_needed(sub, db, force=True) except ValueError as e: raise HTTPException(400, str(e)) return _sub_dict(sub) # ── Event overrides (edit/delete iCal events locally) ───── class ICalEventUpdate(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 color: Optional[str] = None @router.put("/events/{sub_id}/{event_uid}") def update_ical_event( sub_id: int, event_uid: str, data: ICalEventUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): sub = ( db.query(models.ICalSubscription) .filter( models.ICalSubscription.id == sub_id, models.ICalSubscription.user_id == current_user.id, ) .first() ) if not sub: raise HTTPException(404, "Subscription not found") ov = ( db.query(models.ICalOverride) .filter( models.ICalOverride.subscription_id == sub_id, models.ICalOverride.event_uid == event_uid, ) .first() ) if not ov: ov = models.ICalOverride(subscription_id=sub_id, event_uid=event_uid) db.add(ov) if data.title is not None: ov.title = data.title if data.start is not None: ov.start = data.start if data.end is not None: ov.end = data.end if data.allDay is not None: ov.all_day = data.allDay if data.location is not None: ov.location = data.location if data.description is not None: ov.description = data.description if data.color is not None: ov.color = data.color db.commit() return {"ok": True} @router.delete("/events/{sub_id}/{event_uid}") def delete_ical_event( sub_id: int, event_uid: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): sub = ( db.query(models.ICalSubscription) .filter( models.ICalSubscription.id == sub_id, models.ICalSubscription.user_id == current_user.id, ) .first() ) if not sub: raise HTTPException(404, "Subscription not found") ov = ( db.query(models.ICalOverride) .filter( models.ICalOverride.subscription_id == sub_id, models.ICalOverride.event_uid == event_uid, ) .first() ) if not ov: ov = models.ICalOverride(subscription_id=sub_id, event_uid=event_uid, hidden=True) db.add(ov) else: ov.hidden = True db.commit() return {"ok": True}