import base64 import binascii from typing import Any from urllib.parse import quote from fastapi import HTTPException, status from .graph_client import MicrosoftGraphClient from .graph_fields import EVENT_DEFAULT_SELECT, GROUP_DEFAULT_SELECT, MESSAGE_DEFAULT_SELECT from .schemas import ( CalendarEventRequest, CalendarEventUpdateRequest, DriveUploadRequest, PlannerTaskCreateRequest, PlannerTaskUpdateRequest, ScheduleRequest, SendMailRequest, ) def graph_segment(value: str) -> str: return quote(value, safe="") def graph_path(value: str) -> str: return quote(value.strip("/"), safe="/") class UsersService: def __init__(self, graph: MicrosoftGraphClient) -> None: self._graph = graph async def list_users(self, top: int = 25, search: str | None = None) -> Any: params: dict[str, Any] = {"$top": top} headers = None if search: params["$search"] = f'"displayName:{search}" OR "mail:{search}" OR "userPrincipalName:{search}"' headers = {"ConsistencyLevel": "eventual"} return await self._graph.request("GET", "/users", params=params, headers=headers) async def get_user(self, user_id: str) -> Any: return await self._graph.request("GET", f"/users/{graph_segment(user_id)}") class MailService: def __init__(self, graph: MicrosoftGraphClient) -> None: self._graph = graph async def list_messages( self, user_id: str, folder: str = "Inbox", top: int = 25, select: str | None = None ) -> Any: params = { "$top": top, "$orderby": "receivedDateTime desc", "$select": select or MESSAGE_DEFAULT_SELECT, } return await self._graph.request( "GET", f"/users/{graph_segment(user_id)}/mailFolders/{graph_segment(folder)}/messages", params=params, ) async def send_mail(self, user_id: str, request: SendMailRequest) -> None: message: dict[str, Any] = { "subject": request.subject, "body": { "contentType": request.body_content_type, "content": request.body, }, "toRecipients": [recipient.as_graph_recipient() for recipient in request.to], } if request.cc: message["ccRecipients"] = [recipient.as_graph_recipient() for recipient in request.cc] if request.bcc: message["bccRecipients"] = [recipient.as_graph_recipient() for recipient in request.bcc] if request.reply_to: message["replyTo"] = [recipient.as_graph_recipient() for recipient in request.reply_to] if request.attachments: message["attachments"] = [attachment.as_graph_attachment() for attachment in request.attachments] await self._graph.request( "POST", f"/users/{graph_segment(user_id)}/sendMail", json={"message": message, "saveToSentItems": request.save_to_sent_items}, ) def _with_body(select: str) -> str: fields = [f.strip() for f in select.split(",") if f.strip()] for extra in ("body", "bodyPreview"): if extra not in fields: fields.append(extra) return ",".join(fields) class CalendarService: def __init__(self, graph: MicrosoftGraphClient) -> None: self._graph = graph async def get_calendar(self, user_id: str) -> Any: """Default calendar of the mailbox (id, name, owner). Cheapest read-only access check.""" return await self._graph.request("GET", f"/users/{graph_segment(user_id)}/calendar") async def list_events(self, user_id: str, top: int = 25, select: str | None = None) -> Any: params = { "$top": top, "$orderby": "start/dateTime", "$select": select or EVENT_DEFAULT_SELECT, } return await self._graph.request("GET", f"/users/{graph_segment(user_id)}/events", params=params) async def list_calendar_view( self, user_id: str, start: str, end: str, top: int = 25, time_zone: str | None = None, include_body: bool = False, body_type: str = "html", select: str | None = None, ) -> Any: fields = select or EVENT_DEFAULT_SELECT params = { "startDateTime": start, "endDateTime": end, "$top": top, "$orderby": "start/dateTime", "$select": _with_body(fields) if include_body else fields, } prefer: list[str] = [] if time_zone: prefer.append(f'outlook.timezone="{time_zone}"') if include_body: prefer.append(f'outlook.body-content-type="{body_type}"') headers = {"Prefer": ", ".join(prefer)} if prefer else None return await self._graph.request( "GET", f"/users/{graph_segment(user_id)}/calendarView", params=params, headers=headers, ) async def get_schedule(self, user_id: str, request: ScheduleRequest) -> Any: return await self._graph.request( "POST", f"/users/{graph_segment(user_id)}/calendar/getSchedule", json=request.as_graph_payload(), ) async def get_event(self, user_id: str, event_id: str, select: str | None = None) -> Any: return await self._graph.request( "GET", f"/users/{graph_segment(user_id)}/events/{graph_segment(event_id)}", params={"$select": select or _with_body(EVENT_DEFAULT_SELECT)}, ) async def update_event(self, user_id: str, event_id: str, request: CalendarEventUpdateRequest) -> Any: payload = request.as_graph_patch() if not payload: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Calendar event update must contain at least one field.", ) return await self._graph.request( "PATCH", f"/users/{graph_segment(user_id)}/events/{graph_segment(event_id)}", json=payload, ) async def delete_event(self, user_id: str, event_id: str) -> None: await self._graph.request( "DELETE", f"/users/{graph_segment(user_id)}/events/{graph_segment(event_id)}", ) async def create_event(self, user_id: str, request: CalendarEventRequest) -> Any: payload: dict[str, Any] = { "subject": request.subject, "start": request.start.model_dump(by_alias=True), "end": request.end.model_dump(by_alias=True), "attendees": [attendee.as_graph_attendee() for attendee in request.attendees], "isOnlineMeeting": request.is_online_meeting, } if request.body is not None: payload["body"] = {"contentType": request.body_content_type, "content": request.body} if request.location: payload["location"] = {"displayName": request.location} if request.online_meeting_provider: payload["onlineMeetingProvider"] = request.online_meeting_provider return await self._graph.request("POST", f"/users/{graph_segment(user_id)}/events", json=payload) class PlannerService: """Microsoft Planner. Updates and deletes require the task ETag (Graph optimistic concurrency).""" def __init__(self, graph: MicrosoftGraphClient) -> None: self._graph = graph async def list_group_plans(self, group_id: str) -> Any: return await self._graph.request("GET", f"/groups/{graph_segment(group_id)}/planner/plans") async def list_plan_buckets(self, plan_id: str) -> Any: return await self._graph.request("GET", f"/planner/plans/{graph_segment(plan_id)}/buckets") async def list_plan_tasks(self, plan_id: str) -> Any: return await self._graph.request("GET", f"/planner/plans/{graph_segment(plan_id)}/tasks") async def get_task(self, task_id: str) -> Any: return await self._graph.request("GET", f"/planner/tasks/{graph_segment(task_id)}") async def create_task(self, request: PlannerTaskCreateRequest) -> Any: return await self._graph.request("POST", "/planner/tasks", json=request.as_graph_payload()) async def update_task(self, task_id: str, request: PlannerTaskUpdateRequest, if_match: str | None) -> Any: payload = request.as_graph_patch() if not payload: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Planner task update must contain at least one field.", ) etag = await self._resolve_etag(task_id, if_match) return await self._graph.request( "PATCH", f"/planner/tasks/{graph_segment(task_id)}", json=payload, headers={"If-Match": etag, "Prefer": "return=representation"}, ) async def delete_task(self, task_id: str, if_match: str | None) -> None: etag = await self._resolve_etag(task_id, if_match) await self._graph.request( "DELETE", f"/planner/tasks/{graph_segment(task_id)}", headers={"If-Match": etag}, ) async def _resolve_etag(self, task_id: str, if_match: str | None) -> str: if if_match: return if_match task = await self.get_task(task_id) etag = task.get("@odata.etag") if isinstance(task, dict) else None if not etag: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail={"message": "Microsoft Graph did not return an ETag for the Planner task.", "task_id": task_id}, ) return etag class DriveService: def __init__(self, graph: MicrosoftGraphClient) -> None: self._graph = graph async def list_root_children(self, user_id: str, top: int = 25) -> Any: return await self._graph.request( "GET", f"/users/{graph_segment(user_id)}/drive/root/children", params={"$top": top}, ) async def upload_small_file(self, user_id: str, path: str, request: DriveUploadRequest) -> Any: try: content = base64.b64decode(request.content_base64, validate=True) except binascii.Error as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="content_base64 must contain valid base64 data.", ) from exc return await self._graph.request( "PUT", f"/users/{graph_segment(user_id)}/drive/root:/{graph_path(path)}:/content", content=content, headers={"Content-Type": request.content_type}, ) class GroupsService: def __init__(self, graph: MicrosoftGraphClient) -> None: self._graph = graph async def list_groups(self, top: int = 25, select: str | None = None) -> Any: params = {"$top": top, "$select": select or GROUP_DEFAULT_SELECT} return await self._graph.request("GET", "/groups", params=params) async def list_team_channels(self, team_id: str) -> Any: return await self._graph.request("GET", f"/teams/{graph_segment(team_id)}/channels")