Podle zadani "MS365 prava konektoru": - Calendar: calendarView, getSchedule, get/patch/delete udalosti - Planner: plany skupiny, buckety, tasky, create/patch/delete s ETag - GET /admin-consent/url pro onboarding klientskeho tenantu - examples/csharp/ListUsersTop10: ukazka volani GET /users?top=10 vcetne sifrovani hlavicek X-MS365-* - README: multitenant napojeni, endpointy, opravneni, zaznam zmen Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
269 lines
10 KiB
Python
269 lines
10 KiB
Python
import base64
|
|
import binascii
|
|
from typing import Any
|
|
from urllib.parse import quote
|
|
|
|
from fastapi import HTTPException, status
|
|
|
|
from .graph_client import MicrosoftGraphClient
|
|
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) -> Any:
|
|
params = {
|
|
"$top": top,
|
|
"$orderby": "receivedDateTime desc",
|
|
"$select": "id,subject,from,toRecipients,receivedDateTime,hasAttachments,isRead,webLink",
|
|
}
|
|
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},
|
|
)
|
|
|
|
|
|
CALENDAR_EVENT_SELECT = (
|
|
"id,subject,start,end,location,attendees,webLink,isOnlineMeeting,onlineMeeting,showAs,isCancelled,organizer"
|
|
)
|
|
|
|
|
|
class CalendarService:
|
|
def __init__(self, graph: MicrosoftGraphClient) -> None:
|
|
self._graph = graph
|
|
|
|
async def list_events(self, user_id: str, top: int = 25) -> Any:
|
|
params = {
|
|
"$top": top,
|
|
"$orderby": "start/dateTime",
|
|
"$select": CALENDAR_EVENT_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,
|
|
) -> Any:
|
|
params = {
|
|
"startDateTime": start,
|
|
"endDateTime": end,
|
|
"$top": top,
|
|
"$orderby": "start/dateTime",
|
|
"$select": CALENDAR_EVENT_SELECT,
|
|
}
|
|
headers = {"Prefer": f'outlook.timezone="{time_zone}"'} if time_zone 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) -> Any:
|
|
return await self._graph.request(
|
|
"GET",
|
|
f"/users/{graph_segment(user_id)}/events/{graph_segment(event_id)}",
|
|
params={"$select": CALENDAR_EVENT_SELECT + ",body"},
|
|
)
|
|
|
|
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) -> Any:
|
|
params = {"$top": top, "$select": "id,displayName,mail,groupTypes,securityEnabled,mailEnabled"}
|
|
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")
|