321 lines
9.6 KiB
Python
321 lines
9.6 KiB
Python
"""
|
|
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
|