""" Microsoft Calendar Service Handles OAuth2 authentication and event synchronization with Microsoft Graph API """ from msal import ConfidentialClientApplication import requests import os from datetime import datetime, timezone from typing import Optional, Dict, Any from fastapi import HTTPException class MSCalendarService: """ Microsoft Calendar Service using MSAL and Microsoft Graph API """ def __init__(self): """Initialize MSAL client with credentials from environment""" self.client_id = os.getenv('MS_CALENDAR_CLIENT_ID') self.client_secret = os.getenv('MS_CALENDAR_CLIENT_SECRET') self.tenant_id = os.getenv('MS_CALENDAR_TENANT_ID') self.redirect_uri = os.getenv('MS_CALENDAR_REDIRECT_URI') if not all([self.client_id, self.client_secret, self.tenant_id]): raise ValueError("Microsoft Calendar credentials not properly configured in environment variables") # Initialize MSAL Confidential Client self.app = ConfidentialClientApplication( client_id=self.client_id, client_credential=self.client_secret, authority=f"https://login.microsoftonline.com/{self.tenant_id}" ) # Microsoft Graph API endpoints self.graph_url = "https://graph.microsoft.com/v1.0" self.scopes = ["https://graph.microsoft.com/.default"] def get_access_token(self) -> str: """ Get access token using client credentials flow Returns: str: Access token for Microsoft Graph API Raises: HTTPException: If token acquisition fails """ try: result = self.app.acquire_token_for_client(scopes=self.scopes) if "access_token" in result: return result["access_token"] else: error = result.get("error_description", "Unknown error") raise HTTPException( status_code=500, detail=f"Failed to acquire access token: {error}" ) except Exception as e: raise HTTPException( status_code=500, detail=f"Microsoft authentication error: {str(e)}" ) def _make_graph_request( self, method: str, endpoint: str, data: Optional[Dict[Any, Any]] = None ) -> Dict[Any, Any]: """ Make an authenticated request to Microsoft Graph API Args: method: HTTP method (GET, POST, PATCH, DELETE) endpoint: API endpoint path (e.g., "/me/events") data: Request body data Returns: Dict: Response JSON Raises: HTTPException: If request fails """ token = self.get_access_token() headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json" } url = f"{self.graph_url}{endpoint}" try: if method.upper() == "GET": response = requests.get(url, headers=headers) elif method.upper() == "POST": response = requests.post(url, headers=headers, json=data) elif method.upper() == "PATCH": response = requests.patch(url, headers=headers, json=data) elif method.upper() == "DELETE": response = requests.delete(url, headers=headers) else: raise ValueError(f"Unsupported HTTP method: {method}") response.raise_for_status() # DELETE requests may return 204 No Content if response.status_code == 204: return {} return response.json() except requests.exceptions.HTTPError as e: raise HTTPException( status_code=e.response.status_code, detail=f"Microsoft Graph API error: {e.response.text}" ) except Exception as e: raise HTTPException( status_code=500, detail=f"Request to Microsoft Graph failed: {str(e)}" ) async def create_event( self, title: str, description: str, location: str, start_at: datetime, end_at: datetime, calendar_id: str = "primary" ) -> str: """ Create an event in Microsoft Calendar Args: title: Event title description: Event description location: Event location start_at: Event start datetime (timezone-aware) end_at: Event end datetime (timezone-aware) calendar_id: Calendar ID (default: "primary") Returns: str: Microsoft Calendar Event ID Raises: HTTPException: If event creation fails """ event_data = { "subject": title, "body": { "contentType": "HTML", "content": description or "" }, "start": { "dateTime": start_at.isoformat(), "timeZone": "UTC" }, "end": { "dateTime": end_at.isoformat(), "timeZone": "UTC" }, "location": { "displayName": location } } # Use /me/events for primary calendar or /me/calendars/{id}/events for specific calendar endpoint = "/me/events" if calendar_id == "primary" else f"/me/calendars/{calendar_id}/events" result = self._make_graph_request("POST", endpoint, event_data) return result.get("id") async def update_event( self, event_id: str, title: Optional[str] = None, description: Optional[str] = None, location: Optional[str] = None, start_at: Optional[datetime] = None, end_at: Optional[datetime] = None ) -> bool: """ Update an existing event in Microsoft Calendar Args: event_id: Microsoft Calendar Event ID title: Updated event title (optional) description: Updated description (optional) location: Updated location (optional) start_at: Updated start datetime (optional) end_at: Updated end datetime (optional) Returns: bool: True if successful Raises: HTTPException: If update fails """ event_data = {} if title: event_data["subject"] = title if description is not None: event_data["body"] = { "contentType": "HTML", "content": description } if location: event_data["location"] = {"displayName": location} if start_at: event_data["start"] = { "dateTime": start_at.isoformat(), "timeZone": "UTC" } if end_at: event_data["end"] = { "dateTime": end_at.isoformat(), "timeZone": "UTC" } if not event_data: return True # Nothing to update endpoint = f"/me/events/{event_id}" self._make_graph_request("PATCH", endpoint, event_data) return True async def delete_event(self, event_id: str) -> bool: """ Delete an event from Microsoft Calendar Args: event_id: Microsoft Calendar Event ID Returns: bool: True if successful Raises: HTTPException: If deletion fails """ endpoint = f"/me/events/{event_id}" self._make_graph_request("DELETE", endpoint) return True async def get_event(self, event_id: str) -> Dict[Any, Any]: """ Get event details from Microsoft Calendar Args: event_id: Microsoft Calendar Event ID Returns: Dict: Event details Raises: HTTPException: If retrieval fails """ endpoint = f"/me/events/{event_id}" return self._make_graph_request("GET", endpoint) async def sync_event( self, loaf_event, existing_ms_event_id: Optional[str] = None ) -> str: """ Sync a LOAF event to Microsoft Calendar Creates new event if existing_ms_event_id is None, otherwise updates Args: loaf_event: SQLAlchemy Event model instance existing_ms_event_id: Existing Microsoft Calendar Event ID (optional) Returns: str: Microsoft Calendar Event ID Raises: HTTPException: If sync fails """ if existing_ms_event_id: # Update existing event await self.update_event( event_id=existing_ms_event_id, title=loaf_event.title, description=loaf_event.description, location=loaf_event.location, start_at=loaf_event.start_at, end_at=loaf_event.end_at ) return existing_ms_event_id else: # Create new event return await self.create_event( title=loaf_event.title, description=loaf_event.description or "", location=loaf_event.location, start_at=loaf_event.start_at, end_at=loaf_event.end_at ) # Singleton instance _ms_calendar = None def get_ms_calendar_service() -> MSCalendarService: """ Get singleton instance of MSCalendarService Returns: MSCalendarService: Initialized Microsoft Calendar service """ global _ms_calendar if _ms_calendar is None: _ms_calendar = MSCalendarService() return _ms_calendar