Update New Features
This commit is contained in:
320
ms_calendar_service.py
Normal file
320
ms_calendar_service.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user