""" Calendar Service - FastAPI application for CalDAV integration Provides unified API for Sofiia agent to manage calendars """ import os import logging from contextlib import asynccontextmanager from typing import Optional, List, Dict, Any from datetime import datetime from fastapi import FastAPI, HTTPException, Header, Depends, Request from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, declarative_base from calendar_client import CalDAVClient from storage import CalendarStorage from reminder_worker import ReminderWorker # Configure logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # Database DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./calendar.db") engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() def get_db(): """Database session dependency""" db = SessionLocal() try: yield db finally: db.close() # ============================================================================= # MODELS # ============================================================================= class ConnectRequest(BaseModel): workspace_id: str user_id: str username: str password: str provider: str = "radicale" class CreateEventRequest(BaseModel): title: str start: str # ISO 8601 end: str # ISO 8601 timezone: str = "Europe/Kiev" location: Optional[str] = None description: Optional[str] = None attendees: Optional[List[str]] = None idempotency_key: Optional[str] = None class UpdateEventRequest(BaseModel): title: Optional[str] = None start: Optional[str] = None end: Optional[str] = None timezone: str = "Europe/Kiev" location: Optional[str] = None description: Optional[str] = None class SetReminderRequest(BaseModel): event_uid: str remind_at: str # ISO 8601 channel: str = "inapp" # inapp, telegram, email class CalendarToolRequest(BaseModel): action: str workspace_id: str user_id: str provider: str = "radicale" account_id: Optional[str] = None calendar_id: Optional[str] = None params: Dict[str, Any] = Field(default_factory=dict) # ============================================================================= # APP SETUP # ============================================================================= @asynccontextmanager async def lifespan(app: FastAPI): """Startup and shutdown events""" # Startup logger.info("Starting calendar service...") # Create tables Base.metadata.create_all(bind=engine) # Initialize storage storage = CalendarStorage(SessionLocal()) app.state.storage = storage # Initialize reminder worker worker = ReminderWorker(storage) app.state.reminder_worker = worker worker.start() yield # Shutdown logger.info("Shutting down calendar service...") worker.stop() app = FastAPI( title="Calendar Service", description="CalDAV integration for DAARION agents", version="1.0.0", lifespan=lifespan ) # CORS app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # ============================================================================= # AUTH & RBAC # ============================================================================= async def verify_agent( x_agent_id: str = Header(default="anonymous"), x_workspace_id: str = Header(default=None) ) -> Dict[str, str]: """Verify agent authorization""" # In production, verify against RBAC system return { "agent_id": x_agent_id, "workspace_id": x_workspace_id or "default" } # ============================================================================= # CONNECTION MANAGEMENT # ============================================================================= @app.post("/v1/calendar/connect/radicale") async def connect_radicale( request: ConnectRequest, auth: Dict = Depends(verify_agent), db=Depends(get_db) ): """Connect a Radicale CalDAV account""" storage: CalendarStorage = app.state.storage try: # Create CalDAV client and test connection client = CalDAVClient( server_url=os.getenv("RADICALE_URL", "https://caldav.daarion.space"), username=request.username, password=request.password ) # Test connection calendars = client.list_calendars() # Find default calendar default_cal = None for cal in calendars: if cal.get("display_name") in ["default", "Calendar", "Personal"]: default_cal = cal.get("id") break if not default_cal and calendars: default_cal = calendars[0].get("id") # Store account account = storage.create_account( workspace_id=request.workspace_id, user_id=request.user_id, provider=request.provider, username=request.username, password=request.password, principal_url=client.principal_url, default_calendar_id=default_cal ) return { "status": "connected", "account_id": account.id, "calendars": calendars, "default_calendar": default_cal } except Exception as e: logger.error(f"Failed to connect account: {e}") raise HTTPException(status_code=400, detail=str(e)) @app.get("/v1/calendar/accounts") async def list_accounts( workspace_id: str, user_id: str, auth: Dict = Depends(verify_agent), db=Depends(get_db) ): """List connected calendar accounts""" storage: CalendarStorage = app.state.storage accounts = storage.list_accounts(workspace_id, user_id) return { "accounts": [ { "id": a.id, "provider": a.provider, "username": a.username, "default_calendar_id": a.default_calendar_id, "created_at": a.created_at.isoformat() } for a in accounts ] } # ============================================================================= # CALENDAR OPERATIONS # ============================================================================= @app.get("/v1/calendar/calendars") async def list_calendars( account_id: str, auth: Dict = Depends(verify_agent), db=Depends(get_db) ): """List calendars for an account""" storage: CalendarStorage = app.state.storage account = storage.get_account(account_id) if not account: raise HTTPException(status_code=404, detail="Account not found") client = CalDAVClient( server_url=os.getenv("RADICALE_URL", "https://caldav.daarion.space"), username=account.username, password=account.password ) calendars = client.list_calendars() return {"calendars": calendars} @app.get("/v1/calendar/events") async def list_events( account_id: str, calendar_id: Optional[str] = None, time_min: Optional[str] = None, time_max: Optional[str] = None, q: Optional[str] = None, auth: Dict = Depends(verify_agent), db=Depends(get_db) ): """List events from calendar""" storage: CalendarStorage = app.state.storage account = storage.get_account(account_id) if not account: raise HTTPException(status_code=404, detail="Account not found") calendar_id = calendar_id or account.default_calendar_id client = CalDAVClient( server_url=os.getenv("RADICALE_URL", "https://caldav.daarion.space"), username=account.username, password=account.password ) events = client.list_events( calendar_id=calendar_id, time_min=time_min, time_max=time_max ) # Filter by query if provided if q: events = [e for e in events if q.lower() in e.get("title", "").lower()] return {"events": events} @app.get("/v1/calendar/events/{uid}") async def get_event( uid: str, account_id: str, calendar_id: Optional[str] = None, auth: Dict = Depends(verify_agent), db=Depends(get_db) ): """Get single event""" storage: CalendarStorage = app.state.storage account = storage.get_account(account_id) if not account: raise HTTPException(status_code=404, detail="Account not found") calendar_id = calendar_id or account.default_calendar_id client = CalDAVClient( server_url=os.getenv("RADICALE_URL", "https://caldav.daarion.space"), username=account.username, password=account.password ) event = client.get_event(uid, calendar_id) if not event: raise HTTPException(status_code=404, detail="Event not found") return {"event": event} @app.post("/v1/calendar/events") async def create_event( request: CreateEventRequest, account_id: str, calendar_id: Optional[str] = None, auth: Dict = Depends(verify_agent), db=Depends(get_db) ): """Create new calendar event""" storage: CalendarStorage = app.state.storage # Check idempotency if request.idempotency_key: existing = storage.get_by_idempotency_key(request.idempotency_key) if existing: return { "status": "already_exists", "event_uid": existing.event_uid, "message": "Event already exists" } account = storage.get_account(account_id) if not account: raise HTTPException(status_code=404, detail="Account not found") calendar_id = calendar_id or account.default_calendar_id client = CalDAVClient( server_url=os.getenv("RADICALE_URL", "https://caldav.daarion.space"), username=account.username, password=account.password ) event_uid = client.create_event( calendar_id=calendar_id, title=request.title, start=request.start, end=request.end, timezone=request.timezone, location=request.location, description=request.description, attendees=request.attendees ) # Store idempotency key if request.idempotency_key: storage.store_idempotency_key( request.idempotency_key, account.workspace_id, account.user_id, event_uid ) return { "status": "created", "event_uid": event_uid } @app.patch("/v1/calendar/events/{uid}") async def update_event( uid: str, request: UpdateEventRequest, account_id: str, calendar_id: Optional[str] = None, auth: Dict = Depends(verify_agent), db=Depends(get_db) ): """Update existing event""" storage: CalendarStorage = app.state.storage account = storage.get_account(account_id) if not account: raise HTTPException(status_code=404, detail="Account not found") calendar_id = calendar_id or account.default_calendar_id client = CalDAVClient( server_url=os.getenv("RADICALE_URL", "https://caldav.daarion.space"), username=account.username, password=account.password ) client.update_event( uid=uid, calendar_id=calendar_id, title=request.title, start=request.start, end=request.end, timezone=request.timezone, location=request.location, description=request.description ) return {"status": "updated", "event_uid": uid} @app.delete("/v1/calendar/events/{uid}") async def delete_event( uid: str, account_id: str, calendar_id: Optional[str] = None, auth: Dict = Depends(verify_agent), db=Depends(get_db) ): """Delete event""" storage: CalendarStorage = app.state.storage account = storage.get_account(account_id) if not account: raise HTTPException(status_code=404, detail="Account not found") calendar_id = calendar_id or account.default_calendar_id client = CalDAVClient( server_url=os.getenv("RADICALE_URL", "https://caldav.daarion.space"), username=account.username, password=account.password ) client.delete_event(uid, calendar_id) return {"status": "deleted", "event_uid": uid} # ============================================================================= # REMINDERS # ============================================================================= @app.post("/v1/calendar/reminders") async def set_reminder( request: SetReminderRequest, account_id: str, auth: Dict = Depends(verify_agent), db=Depends(get_db) ): """Set event reminder""" storage: CalendarStorage = app.state.storage reminder = storage.create_reminder( workspace_id=auth["workspace_id"], user_id=auth["user_id"], account_id=account_id, event_uid=request.event_uid, remind_at=request.remind_at, channel=request.channel ) return { "status": "created", "reminder_id": reminder.id } # ============================================================================= # CALENDAR TOOL FOR SOFIIA # ============================================================================= @app.post("/v1/tools/calendar") async def calendar_tool( request: CalendarToolRequest, auth: Dict = Depends(verify_agent), db=Depends(get_db) ): """ Unified calendar tool endpoint for Sofiia agent. Actions: - connect: Connect Radicale account - list_calendars: List available calendars - list_events: List events in calendar - get_event: Get single event - create_event: Create new event - update_event: Update event - delete_event: Delete event - set_reminder: Set reminder """ storage: CalendarStorage = app.state.storage try: if request.action == "connect": # Connect account params = request.params connect_req = ConnectRequest( workspace_id=request.workspace_id, user_id=request.user_id, username=params.get("username"), password=params.get("password") ) # Reuse connect logic result = await connect_radicale(connect_req, auth, db) return {"status": "succeeded", "data": result} elif request.action == "list_calendars": if not request.account_id: raise HTTPException(status_code=400, detail="account_id required") return await list_calendars(request.account_id, auth, db) elif request.action == "list_events": if not request.account_id: raise HTTPException(status_code=400, detail="account_id required") params = request.params return await list_events( request.account_id, request.calendar_id, params.get("time_min"), params.get("time_max"), params.get("q"), auth, db ) elif request.action == "get_event": if not request.account_id: raise HTTPException(status_code=400, detail="account_id required") params = request.params return await get_event( params.get("uid"), request.account_id, request.calendar_id, auth, db ) elif request.action == "create_event": if not request.account_id: raise HTTPException(status_code=400, detail="account_id required") params = request.params create_req = CreateEventRequest(**params) return await create_event( create_req, request.account_id, request.calendar_id, auth, db ) elif request.action == "update_event": if not request.account_id: raise HTTPException(status_code=400, detail="account_id required") params = request.params update_req = UpdateEventRequest(**params) return await update_event( params.get("uid"), update_req, request.account_id, request.calendar_id, auth, db ) elif request.action == "delete_event": if not request.account_id: raise HTTPException(status_code=400, detail="account_id required") params = request.params return await delete_event( params.get("uid"), request.account_id, request.calendar_id, auth, db ) elif request.action == "set_reminder": if not request.account_id: raise HTTPException(status_code=400, detail="account_id required") params = request.params reminder_req = SetReminderRequest(**params) return await set_reminder(reminder_req, request.account_id, auth, db) else: raise HTTPException(status_code=400, detail=f"Unknown action: {request.action}") except HTTPException: raise except Exception as e: logger.error(f"Calendar tool error: {e}") return { "status": "failed", "error": { "code": "internal_error", "message": str(e), "retryable": False } } # ============================================================================= # HEALTH & METRICS # ============================================================================= @app.get("/health") async def health(): """Health check""" return {"status": "healthy"} @app.get("/metrics") async def metrics(): """Service metrics""" storage: CalendarStorage = app.state.storage worker: ReminderWorker = app.state.reminder_worker return { "accounts_count": storage.count_accounts(), "reminders_pending": storage.count_pending_reminders(), "worker_status": worker.get_status() }