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:
co-authored by
Claude Fable 5.1
parent
275bd6c467
commit
fe3c3a736a
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user