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
This commit is contained in:
JiriUhlir
2026-09-23 06:51:05 +02:00
parent d80193da8b
commit fb97a8bf63
4 changed files with 121 additions and 24 deletions
+18 -6
View File
@@ -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
+40
View File
@@ -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)}."
)
+35 -5
View File
@@ -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")
+28 -13
View File
@@ -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: