Files
microsoft-365-service/app/services.py
T
JiriUhlir fb97a8bf63 Parametr select u vypisu, telo udalosti v calendarView
- calendar/view: include_body a body_type (html, text), telo udalosti
  rovnou ve vypisu bez dalsiho volani
- select u vypisu mailu, udalosti, calendarView, jedne udalosti a skupin;
  nahrazuje vychozi sadu poli
- app/graph_fields.py: vychozi sady a seznamy dostupnych poli, Swagger
  je zobrazuje v popisu parametru
- README: endpointy, popis select, zaznam zmen
2026-09-23 06:51:05 +02:00

288 lines
11 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 .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")