Calendar a Planner endpointy, admin consent URL, C# ukazka (1.1.0)

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>
This commit is contained in:
JiriUhlir
2026-09-09 11:52:52 +02:00
co-authored by Claude Fable 5.1
parent 275bd6c467
commit fe3c3a736a
20 changed files with 877 additions and 9 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Settings:
app_name: str = os.getenv("APP_NAME", "Microsoft 365 Service")
app_version: str = os.getenv("APP_VERSION", "1.0.1")
app_version: str = os.getenv("APP_VERSION", "1.1.0")
root_path: str = os.getenv("ROOT_PATH", "")
tenant_id: str = os.getenv("MS365_TENANT_ID", "")
client_id: str = os.getenv("MS365_CLIENT_ID", "")
+147 -3
View File
@@ -1,12 +1,21 @@
from typing import Any
from urllib.parse import urlencode
from fastapi import APIRouter, Body, Depends, Query, Response, status
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response, status
from .config import Settings
from .credentials import get_request_settings
from .graph_client import MicrosoftGraphClient
from .schemas import CalendarEventRequest, DriveUploadRequest, SendMailRequest
from .services import CalendarService, DriveService, GroupsService, MailService, UsersService
from .schemas import (
CalendarEventRequest,
CalendarEventUpdateRequest,
DriveUploadRequest,
PlannerTaskCreateRequest,
PlannerTaskUpdateRequest,
ScheduleRequest,
SendMailRequest,
)
from .services import CalendarService, DriveService, GroupsService, MailService, PlannerService, UsersService
router = APIRouter(tags=["microsoft365"])
@@ -35,6 +44,10 @@ def get_groups_service(graph: MicrosoftGraphClient = Depends(get_graph_client))
return GroupsService(graph)
def get_planner_service(graph: MicrosoftGraphClient = Depends(get_graph_client)) -> PlannerService:
return PlannerService(graph)
@router.get("/status")
def microsoft365_status(request_settings: Settings = Depends(get_request_settings)) -> dict[str, Any]:
return {
@@ -46,6 +59,31 @@ def microsoft365_status(request_settings: Settings = Depends(get_request_setting
}
@router.get("/admin-consent/url")
def admin_consent_url(
redirect_uri: str = Query(..., description="HTTPS callback URL registered on the Entra app registration."),
state: str | None = Query(None, description="Opaque value returned to the callback, e.g. customer id."),
request_settings: Settings = Depends(get_request_settings),
) -> dict[str, Any]:
"""Build the admin consent URL a client tenant administrator must open to approve the multitenant app."""
if not request_settings.tenant_id or not request_settings.client_id:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Tenant id and client id are required to build the admin consent URL.",
)
query: dict[str, str] = {
"client_id": request_settings.client_id,
"scope": request_settings.graph_scope,
"redirect_uri": redirect_uri,
}
if state:
query["state"] = state
return {
"tenant_id": request_settings.tenant_id,
"url": f"https://login.microsoftonline.com/{request_settings.tenant_id}/v2.0/adminconsent?{urlencode(query)}",
}
@router.get("/users")
async def list_users(
top: int = Query(25, ge=1, le=999),
@@ -99,6 +137,56 @@ async def create_event(
return await service.create_event(user_id=user_id, request=request)
@router.get("/users/{user_id}/calendar/view")
async def list_calendar_view(
user_id: str,
start: str = Query(..., description="ISO 8601 with offset, e.g. 2026-09-08T08:00:00+02:00."),
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."),
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)
@router.post("/users/{user_id}/calendar/schedule")
async def get_schedule(
user_id: str,
request: ScheduleRequest,
service: CalendarService = Depends(get_calendar_service),
) -> Any:
return await service.get_schedule(user_id=user_id, request=request)
@router.get("/users/{user_id}/calendar/events/{event_id}")
async def get_event(
user_id: str,
event_id: str,
service: CalendarService = Depends(get_calendar_service),
) -> Any:
return await service.get_event(user_id=user_id, event_id=event_id)
@router.patch("/users/{user_id}/calendar/events/{event_id}")
async def update_event(
user_id: str,
event_id: str,
request: CalendarEventUpdateRequest,
service: CalendarService = Depends(get_calendar_service),
) -> Any:
return await service.update_event(user_id=user_id, event_id=event_id, request=request)
@router.delete("/users/{user_id}/calendar/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_event(
user_id: str,
event_id: str,
service: CalendarService = Depends(get_calendar_service),
) -> Response:
await service.delete_event(user_id=user_id, event_id=event_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/users/{user_id}/drive/root/children")
async def list_drive_root_children(
user_id: str,
@@ -129,3 +217,59 @@ async def list_groups(
@router.get("/teams/{team_id}/channels")
async def list_team_channels(team_id: str, service: GroupsService = Depends(get_groups_service)) -> Any:
return await service.list_team_channels(team_id=team_id)
@router.get("/groups/{group_id}/planner/plans")
async def list_group_plans(group_id: str, service: PlannerService = Depends(get_planner_service)) -> Any:
return await service.list_group_plans(group_id=group_id)
@router.get("/planner/plans/{plan_id}/buckets")
async def list_plan_buckets(plan_id: str, service: PlannerService = Depends(get_planner_service)) -> Any:
return await service.list_plan_buckets(plan_id=plan_id)
@router.get("/planner/plans/{plan_id}/tasks")
async def list_plan_tasks(plan_id: str, service: PlannerService = Depends(get_planner_service)) -> Any:
return await service.list_plan_tasks(plan_id=plan_id)
@router.post("/planner/tasks", status_code=status.HTTP_201_CREATED)
async def create_planner_task(
request: PlannerTaskCreateRequest,
service: PlannerService = Depends(get_planner_service),
) -> Any:
return await service.create_task(request=request)
@router.get("/planner/tasks/{task_id}")
async def get_planner_task(task_id: str, service: PlannerService = Depends(get_planner_service)) -> Any:
return await service.get_task(task_id=task_id)
@router.patch("/planner/tasks/{task_id}")
async def update_planner_task(
task_id: str,
request: PlannerTaskUpdateRequest,
if_match: str | None = Header(
None,
alias="If-Match",
description="Task ETag (@odata.etag). When omitted, the service loads the current ETag first.",
),
service: PlannerService = Depends(get_planner_service),
) -> Any:
return await service.update_task(task_id=task_id, request=request, if_match=if_match)
@router.delete("/planner/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_planner_task(
task_id: str,
if_match: str | None = Header(
None,
alias="If-Match",
description="Task ETag (@odata.etag). When omitted, the service loads the current ETag first.",
),
service: PlannerService = Depends(get_planner_service),
) -> Response:
await service.delete_task(task_id=task_id, if_match=if_match)
return Response(status_code=status.HTTP_204_NO_CONTENT)
+119
View File
@@ -69,6 +69,125 @@ class CalendarEventRequest(BaseModel):
online_meeting_provider: str | None = Field(None, pattern="^(teamsForBusiness|skypeForBusiness|skypeForConsumer)$")
class CalendarEventUpdateRequest(BaseModel):
"""Partial update; only fields that are set are sent to Microsoft Graph."""
subject: str | None = None
body: str | None = None
body_content_type: str = Field("HTML", pattern="^(HTML|Text)$")
start: DateTimeTimeZone | None = None
end: DateTimeTimeZone | None = None
location: str | None = None
attendees: list[CalendarAttendee] | None = None
is_online_meeting: bool | None = None
online_meeting_provider: str | None = Field(None, pattern="^(teamsForBusiness|skypeForBusiness|skypeForConsumer)$")
def as_graph_patch(self) -> dict[str, Any]:
payload: dict[str, Any] = {}
if self.subject is not None:
payload["subject"] = self.subject
if self.body is not None:
payload["body"] = {"contentType": self.body_content_type, "content": self.body}
if self.start is not None:
payload["start"] = self.start.model_dump(by_alias=True)
if self.end is not None:
payload["end"] = self.end.model_dump(by_alias=True)
if self.location is not None:
payload["location"] = {"displayName": self.location}
if self.attendees is not None:
payload["attendees"] = [attendee.as_graph_attendee() for attendee in self.attendees]
if self.is_online_meeting is not None:
payload["isOnlineMeeting"] = self.is_online_meeting
if self.online_meeting_provider is not None:
payload["onlineMeetingProvider"] = self.online_meeting_provider
return payload
class ScheduleRequest(BaseModel):
"""Free/busy lookup (Graph getSchedule) for one or more mailboxes."""
schedules: list[EmailStr] = Field(..., min_length=1, description="Mailboxes whose availability is requested.")
start: DateTimeTimeZone
end: DateTimeTimeZone
availability_view_interval: int = Field(30, ge=5, le=1440, description="Slot length in minutes.")
def as_graph_payload(self) -> dict[str, Any]:
return {
"schedules": [str(address) for address in self.schedules],
"startTime": self.start.model_dump(by_alias=True),
"endTime": self.end.model_dump(by_alias=True),
"availabilityViewInterval": self.availability_view_interval,
}
def _planner_assignments(assign_user_ids: list[str], unassign_user_ids: list[str]) -> dict[str, Any]:
assignments: dict[str, Any] = {
user_id: {"@odata.type": "#microsoft.graph.plannerAssignment", "orderHint": " !"}
for user_id in assign_user_ids
}
for user_id in unassign_user_ids:
assignments[user_id] = None
return assignments
class PlannerTaskCreateRequest(BaseModel):
plan_id: str
bucket_id: str | None = None
title: str
start_date_time: str | None = Field(None, description="ISO 8601 with offset, e.g. 2026-09-08T08:00:00Z.")
due_date_time: str | None = Field(None, description="ISO 8601 with offset, e.g. 2026-09-10T16:00:00Z.")
percent_complete: int | None = Field(None, ge=0, le=100)
priority: int | None = Field(None, ge=0, le=10, description="Graph priority: 1 urgent, 3 important, 5 medium, 9 low.")
assign_user_ids: list[str] = Field(default_factory=list, description="Microsoft Graph user ids to assign.")
def as_graph_payload(self) -> dict[str, Any]:
payload: dict[str, Any] = {"planId": self.plan_id, "title": self.title}
if self.bucket_id:
payload["bucketId"] = self.bucket_id
if self.start_date_time:
payload["startDateTime"] = self.start_date_time
if self.due_date_time:
payload["dueDateTime"] = self.due_date_time
if self.percent_complete is not None:
payload["percentComplete"] = self.percent_complete
if self.priority is not None:
payload["priority"] = self.priority
if self.assign_user_ids:
payload["assignments"] = _planner_assignments(self.assign_user_ids, [])
return payload
class PlannerTaskUpdateRequest(BaseModel):
"""Partial update; only fields that are set are sent to Microsoft Graph."""
title: str | None = None
bucket_id: str | None = None
start_date_time: str | None = None
due_date_time: str | None = None
percent_complete: int | None = Field(None, ge=0, le=100)
priority: int | None = Field(None, ge=0, le=10)
assign_user_ids: list[str] = Field(default_factory=list)
unassign_user_ids: list[str] = Field(default_factory=list)
def as_graph_patch(self) -> dict[str, Any]:
payload: dict[str, Any] = {}
if self.title is not None:
payload["title"] = self.title
if self.bucket_id is not None:
payload["bucketId"] = self.bucket_id
if self.start_date_time is not None:
payload["startDateTime"] = self.start_date_time
if self.due_date_time is not None:
payload["dueDateTime"] = self.due_date_time
if self.percent_complete is not None:
payload["percentComplete"] = self.percent_complete
if self.priority is not None:
payload["priority"] = self.priority
if self.assign_user_ids or self.unassign_user_ids:
payload["assignments"] = _planner_assignments(self.assign_user_ids, self.unassign_user_ids)
return payload
class DriveUploadRequest(BaseModel):
content_base64: str
content_type: str = "application/octet-stream"
+128 -2
View File
@@ -6,7 +6,15 @@ from urllib.parse import quote
from fastapi import HTTPException, status
from .graph_client import MicrosoftGraphClient
from .schemas import CalendarEventRequest, DriveUploadRequest, SendMailRequest
from .schemas import (
CalendarEventRequest,
CalendarEventUpdateRequest,
DriveUploadRequest,
PlannerTaskCreateRequest,
PlannerTaskUpdateRequest,
ScheduleRequest,
SendMailRequest,
)
def graph_segment(value: str) -> str:
@@ -74,6 +82,11 @@ class MailService:
)
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
@@ -82,10 +95,66 @@ class CalendarService:
params = {
"$top": top,
"$orderby": "start/dateTime",
"$select": "id,subject,start,end,location,attendees,webLink,isOnlineMeeting,onlineMeeting",
"$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,
@@ -103,6 +172,63 @@ class CalendarService:
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