diff --git a/README.md b/README.md index 299c65c..6fadf62 100644 --- a/README.md +++ b/README.md @@ -88,20 +88,20 @@ GET /version GET /status GET /users?top=25&search=jane GET /users/{user_id} -GET /users/{user_id}/mail/messages?folder=Inbox&top=25 +GET /users/{user_id}/mail/messages?folder=Inbox&top=25&select=... POST /users/{user_id}/mail/send GET /admin-consent/url?redirect_uri=...&state=... GET /users/{user_id}/calendar -GET /users/{user_id}/calendar/events?top=25 +GET /users/{user_id}/calendar/events?top=25&select=... POST /users/{user_id}/calendar/events -GET /users/{user_id}/calendar/view?start=...&end=...&top=25&time_zone=Europe/Prague +GET /users/{user_id}/calendar/view?start=...&end=...&top=25&time_zone=Europe/Prague&include_body=true&body_type=text&select=... POST /users/{user_id}/calendar/schedule -GET /users/{user_id}/calendar/events/{event_id} +GET /users/{user_id}/calendar/events/{event_id}?select=... PATCH /users/{user_id}/calendar/events/{event_id} DELETE /users/{user_id}/calendar/events/{event_id} GET /users/{user_id}/drive/root/children?top=25 PUT /users/{user_id}/drive/root/{path} -GET /groups?top=25 +GET /groups?top=25&select=... GET /groups/{group_id}/planner/plans GET /teams/{team_id}/channels GET /planner/plans/{plan_id}/buckets @@ -113,7 +113,13 @@ DELETE /planner/tasks/{task_id} (volitelně hlavička If-Match) ``` Parametry `start` a `end` u calendarView jsou ISO 8601 s offsetem, například -`2026-09-08T08:00:00+02:00`. Datumy u Planneru (`due_date_time`, `start_date_time`) +`2026-09-08T08:00:00+02:00`. Tělo událostí se ve výpisu nevrací, dokud se nepošle +`include_body=true`; `body_type=text` vrátí tělo jako čistý text místo HTML. + +Výpisy mailů, událostí a skupin a načtení jedné události vracejí jen výchozí sadu polí. +Parametr `select` (pole oddělená čárkou) ji nahradí; výchozí sada i seznam dostupných +polí jsou u každého endpointu ve Swaggeru a v `app/graph_fields.py`. Neznámé pole +Graph odmítne a služba vrátí 502 s jeho chybou. Datumy u Planneru (`due_date_time`, `start_date_time`) jsou ISO 8601 s offsetem, například `2026-09-10T16:00:00Z`. Řešitelé se předávají jako Graph user id v `assign_user_ids`, odebírají v `unassign_user_ids`. @@ -155,6 +161,12 @@ Vygenerované OpenAPI UI otevřete na `http://localhost:8000/docs`. ## Záznam změn +- 2026-09-22: parametr `select` u výpisu mailů, událostí, calendarView, jedné události + a skupin. Výchozí sady a seznamy polí v `app/graph_fields.py`, Swagger je zobrazuje. + +- 2026-09-22: `calendar/view` má `include_body` a `body_type` (html, text), tělo události + se dá dotáhnout rovnou ve výpisu bez dalšího volání na jednotlivé události. + - 2026-09-22: šifrování hlaviček `X-MS365-*` odstraněno, hodnoty se posílají tak, jak jsou. Zrušena proměnná `MS365_CREDENTIAL_ENCODING_SECRET` a závislost `cryptography`. - 2026-09-22: `GET /users/{user_id}/calendar` (objekt kalendáře schránky, id a name) pro diff --git a/app/graph_fields.py b/app/graph_fields.py new file mode 100644 index 0000000..615e754 --- /dev/null +++ b/app/graph_fields.py @@ -0,0 +1,40 @@ +"""Selectable Microsoft Graph fields per resource. Single source for defaults and Swagger descriptions.""" + +EVENT_FIELDS = ( + "id", "subject", "bodyPreview", "body", "start", "end", "location", "locations", "attendees", "organizer", + "webLink", "isOnlineMeeting", "onlineMeeting", "onlineMeetingUrl", "onlineMeetingProvider", "showAs", + "isCancelled", "isAllDay", "isDraft", "isOrganizer", "isReminderOn", "reminderMinutesBeforeStart", + "importance", "sensitivity", "categories", "recurrence", "seriesMasterId", "type", "responseStatus", + "responseRequested", "allowNewTimeProposals", "hideAttendees", "hasAttachments", "iCalUId", + "createdDateTime", "lastModifiedDateTime", "changeKey", "originalStart", "originalStartTimeZone", + "originalEndTimeZone", "transactionId", +) +EVENT_DEFAULT_SELECT = ( + "id,subject,start,end,location,attendees,webLink,isOnlineMeeting,onlineMeeting,showAs,isCancelled,organizer" +) + +MESSAGE_FIELDS = ( + "id", "subject", "body", "bodyPreview", "uniqueBody", "from", "sender", "toRecipients", "ccRecipients", + "bccRecipients", "replyTo", "receivedDateTime", "sentDateTime", "createdDateTime", "lastModifiedDateTime", + "hasAttachments", "isRead", "isDraft", "importance", "categories", "conversationId", "conversationIndex", + "internetMessageId", "internetMessageHeaders", "flag", "parentFolderId", "webLink", + "inferenceClassification", "isDeliveryReceiptRequested", "isReadReceiptRequested", "changeKey", +) +MESSAGE_DEFAULT_SELECT = "id,subject,from,toRecipients,receivedDateTime,hasAttachments,isRead,webLink" + +GROUP_FIELDS = ( + "id", "displayName", "description", "mail", "mailNickname", "mailEnabled", "securityEnabled", "groupTypes", + "visibility", "classification", "createdDateTime", "renewedDateTime", "expirationDateTime", + "resourceProvisioningOptions", "resourceBehaviorOptions", "proxyAddresses", "membershipRule", + "membershipRuleProcessingState", "isAssignableToRole", "preferredLanguage", "theme", + "onPremisesSyncEnabled", "deletedDateTime", +) +GROUP_DEFAULT_SELECT = "id,displayName,mail,groupTypes,securityEnabled,mailEnabled" + + +def select_description(default: str, fields: tuple[str, ...]) -> str: + """Swagger text for the select query parameter.""" + return ( + "Comma separated Graph fields to return. Replaces the default selection. " + f"Default: {default}. Available: {', '.join(fields)}." + ) diff --git a/app/routes.py b/app/routes.py index d8dcf4f..ec67623 100644 --- a/app/routes.py +++ b/app/routes.py @@ -6,6 +6,15 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Resp from .config import Settings from .credentials import get_request_settings from .graph_client import MicrosoftGraphClient +from .graph_fields import ( + EVENT_DEFAULT_SELECT, + EVENT_FIELDS, + GROUP_DEFAULT_SELECT, + GROUP_FIELDS, + MESSAGE_DEFAULT_SELECT, + MESSAGE_FIELDS, + select_description, +) from .schemas import ( CalendarEventRequest, CalendarEventUpdateRequest, @@ -48,6 +57,11 @@ def get_planner_service(graph: MicrosoftGraphClient = Depends(get_graph_client)) return PlannerService(graph) +EVENT_SELECT_QUERY = Query(None, description=select_description(EVENT_DEFAULT_SELECT, EVENT_FIELDS)) +MESSAGE_SELECT_QUERY = Query(None, description=select_description(MESSAGE_DEFAULT_SELECT, MESSAGE_FIELDS)) +GROUP_SELECT_QUERY = Query(None, description=select_description(GROUP_DEFAULT_SELECT, GROUP_FIELDS)) + + @router.get("/status") def microsoft365_status(request_settings: Settings = Depends(get_request_settings)) -> dict[str, Any]: return { @@ -103,9 +117,10 @@ async def list_messages( user_id: str, folder: str = "Inbox", top: int = Query(25, ge=1, le=100), + select: str | None = MESSAGE_SELECT_QUERY, service: MailService = Depends(get_mail_service), ) -> Any: - return await service.list_messages(user_id=user_id, folder=folder, top=top) + return await service.list_messages(user_id=user_id, folder=folder, top=top, select=select) @router.post("/users/{user_id}/mail/send", status_code=status.HTTP_202_ACCEPTED) @@ -129,9 +144,10 @@ async def get_calendar(user_id: str, service: CalendarService = Depends(get_cale async def list_events( user_id: str, top: int = Query(25, ge=1, le=100), + select: str | None = EVENT_SELECT_QUERY, service: CalendarService = Depends(get_calendar_service), ) -> Any: - return await service.list_events(user_id=user_id, top=top) + return await service.list_events(user_id=user_id, top=top, select=select) @router.post("/users/{user_id}/calendar/events", status_code=status.HTTP_201_CREATED) @@ -150,9 +166,21 @@ async def list_calendar_view( end: str = Query(..., description="ISO 8601 with offset, e.g. 2026-09-08T18:00:00+02:00."), top: int = Query(25, ge=1, le=100), time_zone: str | None = Query(None, description="Time zone for returned dates, e.g. Europe/Prague."), + include_body: bool = Query(False, description="Also return body and bodyPreview of each event."), + body_type: str = Query("html", pattern="^(html|text)$", description="Body format when include_body is true."), + select: str | None = EVENT_SELECT_QUERY, service: CalendarService = Depends(get_calendar_service), ) -> Any: - return await service.list_calendar_view(user_id=user_id, start=start, end=end, top=top, time_zone=time_zone) + return await service.list_calendar_view( + user_id=user_id, + start=start, + end=end, + top=top, + time_zone=time_zone, + include_body=include_body, + body_type=body_type, + select=select, + ) @router.post("/users/{user_id}/calendar/schedule") @@ -168,9 +196,10 @@ async def get_schedule( async def get_event( user_id: str, event_id: str, + select: str | None = EVENT_SELECT_QUERY, service: CalendarService = Depends(get_calendar_service), ) -> Any: - return await service.get_event(user_id=user_id, event_id=event_id) + return await service.get_event(user_id=user_id, event_id=event_id, select=select) @router.patch("/users/{user_id}/calendar/events/{event_id}") @@ -215,9 +244,10 @@ async def upload_small_file( @router.get("/groups") async def list_groups( top: int = Query(25, ge=1, le=999), + select: str | None = GROUP_SELECT_QUERY, service: GroupsService = Depends(get_groups_service), ) -> Any: - return await service.list_groups(top=top) + return await service.list_groups(top=top, select=select) @router.get("/teams/{team_id}/channels") diff --git a/app/services.py b/app/services.py index 789b53f..bf7cf1f 100644 --- a/app/services.py +++ b/app/services.py @@ -6,6 +6,7 @@ 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, @@ -45,11 +46,13 @@ 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: + 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": "id,subject,from,toRecipients,receivedDateTime,hasAttachments,isRead,webLink", + "$select": select or MESSAGE_DEFAULT_SELECT, } return await self._graph.request( "GET", @@ -82,9 +85,12 @@ class MailService: ) -CALENDAR_EVENT_SELECT = ( - "id,subject,start,end,location,attendees,webLink,isOnlineMeeting,onlineMeeting,showAs,isCancelled,organizer" -) +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: @@ -95,11 +101,11 @@ class CalendarService: """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) -> Any: + async def list_events(self, user_id: str, top: int = 25, select: str | None = None) -> Any: params = { "$top": top, "$orderby": "start/dateTime", - "$select": CALENDAR_EVENT_SELECT, + "$select": select or EVENT_DEFAULT_SELECT, } return await self._graph.request("GET", f"/users/{graph_segment(user_id)}/events", params=params) @@ -110,15 +116,24 @@ class CalendarService: 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": CALENDAR_EVENT_SELECT, + "$select": _with_body(fields) if include_body else fields, } - headers = {"Prefer": f'outlook.timezone="{time_zone}"'} if time_zone else None + 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", @@ -133,11 +148,11 @@ class CalendarService: json=request.as_graph_payload(), ) - async def get_event(self, user_id: str, event_id: str) -> Any: + 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": CALENDAR_EVENT_SELECT + ",body"}, + params={"$select": select or _with_body(EVENT_DEFAULT_SELECT)}, ) async def update_event(self, user_id: str, event_id: str, request: CalendarEventUpdateRequest) -> Any: @@ -264,8 +279,8 @@ 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"} + 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: