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
+68 -2
View File
@@ -173,13 +173,41 @@ using var response = await http.SendAsync(request);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
``` ```
## Multitenant napojení klientů (CSBOT MS365 Connector)
Služba je stavěná pro jednu multitenant Entra aplikaci CSBOT, přes kterou se připojují
Microsoft 365 tenanty jednotlivých klientů. Podrobné zadání je v Notion stránce
"MS365 práva konektoru".
- `MS365_CLIENT_ID` a `MS365_CLIENT_SECRET` patří aplikaci CSBOT a jsou pro všechny klienty stejné.
- `MS365_TENANT_ID` je tenant konkrétního klienta. Token se získává proti
`https://login.microsoftonline.com/{tenant klienta}/oauth2/v2.0/token`.
- Při volání za různé klienty se tenant id posílá v hlavičce `X-MS365-Tenant-Id`
(viz "Credentials v request hlavičkách"), client id a secret zůstávají stejné.
- Klient nevytváří vlastní App Registration ani nepředává secret. Jeho administrátor jen
schválí admin consent. URL pro consent vrací `GET /admin-consent/url?redirect_uri=...&state=...`
(bere tenant id a client id z hlaviček nebo z env). Callback po consentu (parametry `tenant`
a `admin_consent=True`) zpracovává backend CSBOT, ne tato služba.
- Služba je bezstavová a neověřuje, zda mailbox, plan id nebo bucket id patří danému
zákazníkovi. Tenant isolation (customer id -> tenant id, mailbox, plan id, bucket id)
musí držet volající backend, hodnoty nesmí přicházet přímo od LLM.
Postup testu po consentu klienta: `GET /status`, `GET /users/{mailbox}/calendar/view`,
`POST /users/{mailbox}/calendar/events` + `DELETE`, `GET /planner/plans/{plan_id}/tasks`,
`POST /planner/tasks` + `DELETE`.
## Implementované služby ## Implementované služby
- Users: výpis a načtení uživatelů. - Users: výpis a načtení uživatelů.
- Mail: výpis zpráv a odesílání e-mailů včetně příloh. - Mail: výpis zpráv a odesílání e-mailů včetně příloh.
- Calendar: výpis a vytváření událostí včetně Teams online meetingů. - Calendar: výpis událostí, calendarView v zadaném rozsahu, volné termíny (getSchedule),
načtení, vytvoření, úprava a smazání události včetně Teams online meetingů.
- Planner: výpis plánů skupiny, bucketů a tasků plánu, načtení, vytvoření, úprava
a smazání tasku. Úprava a smazání používají ETag (`If-Match`). Když hlavička chybí,
služba si aktuální ETag načte sama.
- OneDrive: výpis souborů v rootu a upload malých souborů. - OneDrive: výpis souborů v rootu a upload malých souborů.
- Groups a Teams: výpis skupin a výpis kanálů týmu. - Groups a Teams: výpis skupin a výpis kanálů týmu.
- Admin consent: sestavení consent URL pro administrátora klientského tenantu.
## API ## API
@@ -191,14 +219,32 @@ GET /users?top=25&search=jane
GET /users/{user_id} GET /users/{user_id}
GET /users/{user_id}/mail/messages?folder=Inbox&top=25 GET /users/{user_id}/mail/messages?folder=Inbox&top=25
POST /users/{user_id}/mail/send POST /users/{user_id}/mail/send
GET /admin-consent/url?redirect_uri=...&state=...
GET /users/{user_id}/calendar/events?top=25 GET /users/{user_id}/calendar/events?top=25
POST /users/{user_id}/calendar/events POST /users/{user_id}/calendar/events
GET /users/{user_id}/calendar/view?start=...&end=...&top=25&time_zone=Europe/Prague
POST /users/{user_id}/calendar/schedule
GET /users/{user_id}/calendar/events/{event_id}
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 GET /users/{user_id}/drive/root/children?top=25
PUT /users/{user_id}/drive/root/{path} PUT /users/{user_id}/drive/root/{path}
GET /groups?top=25 GET /groups?top=25
GET /groups/{group_id}/planner/plans
GET /teams/{team_id}/channels GET /teams/{team_id}/channels
GET /planner/plans/{plan_id}/buckets
GET /planner/plans/{plan_id}/tasks
POST /planner/tasks
GET /planner/tasks/{task_id}
PATCH /planner/tasks/{task_id} (volitelně hlavička If-Match)
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`)
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`.
`user_id` může být Microsoft Graph user id nebo user principal name, například `jane@example.com`. `user_id` může být Microsoft Graph user id nebo user principal name, například `jane@example.com`.
## Požadovaná Graph oprávnění ## Požadovaná Graph oprávnění
@@ -208,10 +254,22 @@ Přidělte pouze oprávnění, která dané nasazení skutečně používá:
- Users: `User.Read.All` - Users: `User.Read.All`
- Čtení mailů: `Mail.Read` - Čtení mailů: `Mail.Read`
- Odesílání mailů: `Mail.Send` - Odesílání mailů: `Mail.Send`
- Čtení a zápis do kalendáře: `Calendars.ReadWrite` - Čtení a zápis do kalendáře, calendarView, getSchedule: `Calendars.ReadWrite`
- Planner: `Tasks.ReadWrite.All` (zahrnuje i čtení, `Tasks.Read.All` netřeba)
- Čtení a zápis do OneDrive: `Files.ReadWrite.All` - Čtení a zápis do OneDrive: `Files.ReadWrite.All`
- Výpis skupin a Teams kanálů: `Group.Read.All`, `Team.ReadBasic.All`, `Channel.ReadBasic.All` - Výpis skupin a Teams kanálů: `Group.Read.All`, `Team.ReadBasic.All`, `Channel.ReadBasic.All`
Pro konektor CSBOT (Calendar + Planner) je minimum `Calendars.ReadWrite` a `Tasks.ReadWrite.All`,
obě jako Application permissions. `Calendars.ReadWrite` platí na všechny mailboxy tenantu,
omezení na konkrétní mailbox se řeší na straně klienta v Exchange Online
(application access policy), ne v této službě.
## Ukázky pro klienty
- `examples/csharp/ListUsersTop10`: konzolová aplikace .NET 8, volá `GET /users?top=10`
a obsahuje třídu `CredentialEncoder` pro šifrování hlaviček `X-MS365-*`.
Popis v `examples/csharp/README.md`.
## Lokální spuštění ## Lokální spuštění
```bash ```bash
@@ -223,3 +281,11 @@ Vygenerované OpenAPI UI otevřete na `http://localhost:8000/docs`.
# job queue test Thu May 28 02:12:07 PM CEST 2026 # job queue test Thu May 28 02:12:07 PM CEST 2026
# job queue test Thu May 28 02:12:28 PM CEST 2026 # job queue test Thu May 28 02:12:28 PM CEST 2026
# websocket logs test Fri May 29 11:03:58 AM CEST 2026 # websocket logs test Fri May 29 11:03:58 AM CEST 2026
## Záznam změn
- 2026-09-09: verze 1.1.0. Podle Notion zadání "MS365 práva konektoru" doplněn Calendar
(calendarView, getSchedule, get/patch/delete události), Planner (plány, buckety, tasky,
create/patch/delete s ETag) a `GET /admin-consent/url`. Kód ověřen jen importem aplikace
a serializací schémat, proti reálnému tenantu netestováno (chybí DEV App Registration).
- 2026-09-09: přidána C# ukázka `examples/csharp/ListUsersTop10` (top 10 z `/users`).
+1 -1
View File
@@ -1,7 +1,7 @@
id: microsoft-365-service id: microsoft-365-service
name: Microsoft 365 Service name: Microsoft 365 Service
language: python language: python
version: 1.0.1 version: 1.1.0
base_path: /apps/microsoft-365-service base_path: /apps/microsoft-365-service
port: 8000 port: 8000
status: development status: development
+1 -1
View File
@@ -5,7 +5,7 @@ from dataclasses import dataclass, replace
@dataclass(frozen=True) @dataclass(frozen=True)
class Settings: class Settings:
app_name: str = os.getenv("APP_NAME", "Microsoft 365 Service") 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", "") root_path: str = os.getenv("ROOT_PATH", "")
tenant_id: str = os.getenv("MS365_TENANT_ID", "") tenant_id: str = os.getenv("MS365_TENANT_ID", "")
client_id: str = os.getenv("MS365_CLIENT_ID", "") client_id: str = os.getenv("MS365_CLIENT_ID", "")
+147 -3
View File
@@ -1,12 +1,21 @@
from typing import Any 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 .config import Settings
from .credentials import get_request_settings from .credentials import get_request_settings
from .graph_client import MicrosoftGraphClient from .graph_client import MicrosoftGraphClient
from .schemas import CalendarEventRequest, DriveUploadRequest, SendMailRequest from .schemas import (
from .services import CalendarService, DriveService, GroupsService, MailService, UsersService CalendarEventRequest,
CalendarEventUpdateRequest,
DriveUploadRequest,
PlannerTaskCreateRequest,
PlannerTaskUpdateRequest,
ScheduleRequest,
SendMailRequest,
)
from .services import CalendarService, DriveService, GroupsService, MailService, PlannerService, UsersService
router = APIRouter(tags=["microsoft365"]) router = APIRouter(tags=["microsoft365"])
@@ -35,6 +44,10 @@ def get_groups_service(graph: MicrosoftGraphClient = Depends(get_graph_client))
return GroupsService(graph) return GroupsService(graph)
def get_planner_service(graph: MicrosoftGraphClient = Depends(get_graph_client)) -> PlannerService:
return PlannerService(graph)
@router.get("/status") @router.get("/status")
def microsoft365_status(request_settings: Settings = Depends(get_request_settings)) -> dict[str, Any]: def microsoft365_status(request_settings: Settings = Depends(get_request_settings)) -> dict[str, Any]:
return { 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") @router.get("/users")
async def list_users( async def list_users(
top: int = Query(25, ge=1, le=999), 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) 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") @router.get("/users/{user_id}/drive/root/children")
async def list_drive_root_children( async def list_drive_root_children(
user_id: str, user_id: str,
@@ -129,3 +217,59 @@ async def list_groups(
@router.get("/teams/{team_id}/channels") @router.get("/teams/{team_id}/channels")
async def list_team_channels(team_id: str, service: GroupsService = Depends(get_groups_service)) -> Any: 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) 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)$") 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): class DriveUploadRequest(BaseModel):
content_base64: str content_base64: str
content_type: str = "application/octet-stream" 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 fastapi import HTTPException, status
from .graph_client import MicrosoftGraphClient 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: 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: class CalendarService:
def __init__(self, graph: MicrosoftGraphClient) -> None: def __init__(self, graph: MicrosoftGraphClient) -> None:
self._graph = graph self._graph = graph
@@ -82,10 +95,66 @@ class CalendarService:
params = { params = {
"$top": top, "$top": top,
"$orderby": "start/dateTime", "$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) 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: async def create_event(self, user_id: str, request: CalendarEventRequest) -> Any:
payload: dict[str, Any] = { payload: dict[str, Any] = {
"subject": request.subject, "subject": request.subject,
@@ -103,6 +172,63 @@ class CalendarService:
return await self._graph.request("POST", f"/users/{graph_segment(user_id)}/events", json=payload) 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: class DriveService:
def __init__(self, graph: MicrosoftGraphClient) -> None: def __init__(self, graph: MicrosoftGraphClient) -> None:
self._graph = graph self._graph = graph
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+128
View File
@@ -0,0 +1,128 @@
// Ukazka volani microsoft-365-service: vypis prvnich 10 uzivatelu z GET /users.
//
// Sluzba: https://services.csbot.cz/apps/microsoft-365-service
// Dokumentace (Swagger): https://services.csbot.cz/apps/microsoft-365-service/docs
//
// Credentials se posilaji v hlavickach X-MS365-*. Kazda hodnota je zasifrovana
// AES-256-GCM klicem odvozenym pres HKDF-SHA256 ze sdileneho secretu
// (MS365_CREDENTIAL_ENCODING_SECRET na strane sluzby). Format hodnoty:
// v1.<base64url nonce>.<base64url ciphertext+tag>
//
// Spusteni:
// set MS365_SERVICE_BASE_URL=https://services.csbot.cz/apps/microsoft-365-service
// set MS365_TENANT_ID=<tenant id>
// set MS365_CLIENT_ID=<client id>
// set MS365_CLIENT_SECRET=<client secret>
// set MS365_CREDENTIAL_ENCODING_SECRET=<sdileny secret>
// dotnet run
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
var baseUrl = Env("MS365_SERVICE_BASE_URL", "https://services.csbot.cz/apps/microsoft-365-service");
var tenantId = Env("MS365_TENANT_ID");
var clientId = Env("MS365_CLIENT_ID");
var clientSecret = Env("MS365_CLIENT_SECRET");
var sharedSecret = Env("MS365_CREDENTIAL_ENCODING_SECRET");
const int top = 10;
using var http = new HttpClient { BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/") };
http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var request = new HttpRequestMessage(HttpMethod.Get, $"users?top={top}");
request.Headers.Add("X-MS365-Tenant-Id", CredentialEncoder.Encode(tenantId, "X-MS365-Tenant-Id", sharedSecret));
request.Headers.Add("X-MS365-Client-Id", CredentialEncoder.Encode(clientId, "X-MS365-Client-Id", sharedSecret));
request.Headers.Add("X-MS365-Client-Secret", CredentialEncoder.Encode(clientSecret, "X-MS365-Client-Secret", sharedSecret));
request.Headers.Add("X-MS365-Credential-Version", "v1");
using var response = await http.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
// 400 = spatne/prosle hlavicky, 502 = chyba z Microsoft Graph (detail je v tele),
// 403 text/plain = request neprosel pres reverzni proxy (IP allowlist pro GET).
Console.Error.WriteLine($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
Console.Error.WriteLine(body);
return 1;
}
// Sluzba vraci odpoved Microsoft Graph beze zmeny: { "value": [ { ...user... } ], "@odata.nextLink": "..." }
using var json = JsonDocument.Parse(body);
var users = json.RootElement.GetProperty("value");
Console.WriteLine($"Nacteno uzivatelu: {users.GetArrayLength()}");
foreach (var user in users.EnumerateArray())
{
var displayName = user.TryGetProperty("displayName", out var dn) ? dn.GetString() : null;
var upn = user.TryGetProperty("userPrincipalName", out var up) ? up.GetString() : null;
var mail = user.TryGetProperty("mail", out var m) && m.ValueKind == JsonValueKind.String ? m.GetString() : null;
var id = user.GetProperty("id").GetString();
Console.WriteLine($"{displayName} | {upn} | {mail ?? "-"} | {id}");
}
return 0;
static string Env(string name, string? fallback = null)
{
var value = Environment.GetEnvironmentVariable(name);
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
if (fallback is not null)
{
return fallback;
}
throw new InvalidOperationException($"Chybi environment promenna {name}.");
}
/// <summary>
/// Sifrovani hodnot pro hlavicky X-MS365-*. Musi presne odpovidat dekodovani na strane sluzby
/// (app/credentials.py): HKDF-SHA256 se salt "microsoft-365-service.credentials.v1"
/// a info = nazev hlavicky, AES-256-GCM s 12B nonce, 16B tagem a AAD = nazev hlavicky.
/// </summary>
static class CredentialEncoder
{
private static readonly byte[] HkdfSalt = Encoding.UTF8.GetBytes("microsoft-365-service.credentials.v1");
public static string Encode(string value, string headerName, string sharedSecret, TimeSpan? validity = null)
{
var now = DateTimeOffset.UtcNow;
var payload = JsonSerializer.Serialize(new
{
value,
issued_at = now.ToString("O"),
expires_at = now.Add(validity ?? TimeSpan.FromHours(1)).ToString("O"),
});
var headerBytes = Encoding.UTF8.GetBytes(headerName);
var key = HKDF.DeriveKey(
HashAlgorithmName.SHA256,
Encoding.UTF8.GetBytes(sharedSecret),
outputLength: 32,
salt: HkdfSalt,
info: headerBytes);
var nonce = RandomNumberGenerator.GetBytes(12);
var plaintext = Encoding.UTF8.GetBytes(payload);
var ciphertext = new byte[plaintext.Length];
var tag = new byte[16];
using var aes = new AesGcm(key, tagSizeInBytes: 16);
aes.Encrypt(nonce, plaintext, ciphertext, tag, associatedData: headerBytes);
var ciphertextAndTag = new byte[ciphertext.Length + tag.Length];
ciphertext.CopyTo(ciphertextAndTag, 0);
tag.CopyTo(ciphertextAndTag, ciphertext.Length);
return $"v1.{Base64Url(nonce)}.{Base64Url(ciphertextAndTag)}";
}
private static string Base64Url(byte[] data) =>
Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
@@ -0,0 +1,4 @@
// <autogenerated />
using System;
using System.Reflection;
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v8.0", FrameworkDisplayName = ".NET 8.0")]
@@ -0,0 +1,22 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("ListUsersTop10")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+275bd6c46764ec8db008fa2fa8c30673bb96f3d8")]
[assembly: System.Reflection.AssemblyProductAttribute("ListUsersTop10")]
[assembly: System.Reflection.AssemblyTitleAttribute("ListUsersTop10")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
// Generated by the MSBuild WriteCodeFragment class.
@@ -0,0 +1 @@
502171e0ece4b0be631032bff07cea1c6b79151ad77b399268a548f2c009e1d0
@@ -0,0 +1,18 @@
is_global = true
build_property.TargetFramework = net8.0
build_property.TargetFrameworkIdentifier = .NETCoreApp
build_property.TargetFrameworkVersion = v8.0
build_property.TargetPlatformMinVersion =
build_property.UsingMicrosoftNETSdkWeb =
build_property.ProjectTypeGuids =
build_property.InvariantGlobalization =
build_property.PlatformNeutralAssembly =
build_property.EnforceExtendedAnalyzerRules =
build_property.EntryPointFilePath =
build_property._SupportedPlatformList = Linux,macOS,Windows
build_property.RootNamespace = ListUsersTop10
build_property.ProjectDir = D:\GitHubRepository\Hracicky\x\ms365\microsoft-365-service\examples\csharp\ListUsersTop10\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.EffectiveAnalysisLevelStyle = 8.0
build_property.EnableCodeStyleSeverity =
@@ -0,0 +1,8 @@
// <auto-generated/>
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Linq;
global using System.Net.Http;
global using System.Threading;
global using System.Threading.Tasks;
@@ -0,0 +1,77 @@
{
"format": 1,
"restore": {
"D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj": {}
},
"projects": {
"D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj": {
"version": "1.0.0",
"restore": {
"projectUniqueName": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj",
"projectName": "ListUsersTop10",
"projectPath": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj",
"packagesPath": "C:\\Users\\GamingPC\\.nuget\\packages\\",
"outputPath": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\obj\\",
"projectStyle": "PackageReference",
"fallbackFolders": [
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
],
"configFilePaths": [
"C:\\Users\\GamingPC\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
],
"originalTargetFrameworks": [
"net8.0"
],
"sources": {
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
"C:\\Program Files\\dotnet\\library-packs": {},
"D:\\CustomNuGetPackages": {},
"https://api.nuget.org/v3/index.json": {}
},
"frameworks": {
"net8.0": {
"framework": "net8.0",
"targetAlias": "net8.0",
"projectReferences": {}
}
},
"warningProperties": {
"warnAsError": [
"NU1605"
]
},
"restoreAuditProperties": {
"enableAudit": "true",
"auditLevel": "low",
"auditMode": "direct"
},
"SdkAnalysisLevel": "10.0.400"
},
"frameworks": {
"net8.0": {
"framework": "net8.0",
"targetAlias": "net8.0",
"imports": [
"net461",
"net462",
"net47",
"net471",
"net472",
"net48",
"net481"
],
"assetTargetFallback": true,
"warn": true,
"frameworkReferences": {
"Microsoft.NETCore.App": {
"privateAssets": "all"
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.400/PortableRuntimeIdentifierGraph.json"
}
}
}
}
}
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<RestoreSuccess Condition=" '$(RestoreSuccess)' == '' ">True</RestoreSuccess>
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">$(UserProfile)\.nuget\packages\</NuGetPackageRoot>
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">C:\Users\GamingPC\.nuget\packages\;C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages</NuGetPackageFolders>
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">7.0.0</NuGetToolVersion>
</PropertyGroup>
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<SourceRoot Include="C:\Users\GamingPC\.nuget\packages\" />
<SourceRoot Include="C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages\" />
</ItemGroup>
</Project>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" />
@@ -0,0 +1,83 @@
{
"version": 4,
"targets": {
"net8.0": {}
},
"libraries": {},
"projectFileDependencyGroups": {
"net8.0": []
},
"packageFolders": {
"C:\\Users\\GamingPC\\.nuget\\packages\\": {},
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages": {}
},
"project": {
"version": "1.0.0",
"restore": {
"projectUniqueName": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj",
"projectName": "ListUsersTop10",
"projectPath": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj",
"packagesPath": "C:\\Users\\GamingPC\\.nuget\\packages\\",
"outputPath": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\obj\\",
"projectStyle": "PackageReference",
"fallbackFolders": [
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
],
"configFilePaths": [
"C:\\Users\\GamingPC\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
],
"originalTargetFrameworks": [
"net8.0"
],
"sources": {
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
"C:\\Program Files\\dotnet\\library-packs": {},
"D:\\CustomNuGetPackages": {},
"https://api.nuget.org/v3/index.json": {}
},
"frameworks": {
"net8.0": {
"framework": "net8.0",
"targetAlias": "net8.0",
"projectReferences": {}
}
},
"warningProperties": {
"warnAsError": [
"NU1605"
]
},
"restoreAuditProperties": {
"enableAudit": "true",
"auditLevel": "low",
"auditMode": "direct"
},
"SdkAnalysisLevel": "10.0.400"
},
"frameworks": {
"net8.0": {
"framework": "net8.0",
"targetAlias": "net8.0",
"imports": [
"net461",
"net462",
"net47",
"net471",
"net472",
"net48",
"net481"
],
"assetTargetFallback": true,
"warn": true,
"frameworkReferences": {
"Microsoft.NETCore.App": {
"privateAssets": "all"
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.400/PortableRuntimeIdentifierGraph.json"
}
}
}
}
@@ -0,0 +1,8 @@
{
"version": 2,
"dgSpecHash": "4FfrW7UlSBU=",
"success": true,
"projectFilePath": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj",
"expectedPackageFiles": [],
"logs": []
}
+36
View File
@@ -0,0 +1,36 @@
# Ukazky volani sluzby v C#
## ListUsersTop10
Konzolova aplikace (.NET 8), ktera zavola `GET /users?top=10` a vypise
displayName, userPrincipalName, mail a id kazdeho uzivatele.
Obsahuje tridu `CredentialEncoder`, ktera sifruje hodnoty pro hlavicky
`X-MS365-Tenant-Id`, `X-MS365-Client-Id` a `X-MS365-Client-Secret` presne tak,
jak je sluzba dekoduje v `app/credentials.py`. Tridu lze zkopirovat do
libovolneho projektu, nema zadne externi zavislosti.
### Spusteni
```powershell
cd examples/csharp/ListUsersTop10
$env:MS365_SERVICE_BASE_URL = "https://services.csbot.cz/apps/microsoft-365-service"
$env:MS365_TENANT_ID = "<tenant id>"
$env:MS365_CLIENT_ID = "<client id>"
$env:MS365_CLIENT_SECRET = "<client secret>"
$env:MS365_CREDENTIAL_ENCODING_SECRET = "<sdileny secret, stejny jako na serveru>"
dotnet run
```
### Co ocekavat
- `200` a JSON ve tvaru Microsoft Graph: `{ "value": [ ... ], "@odata.nextLink": "..." }`.
- `400` kdyz hlavicky chybi, jsou neuplne, prosle nebo je spatny sdileny secret.
- `502` kdyz Microsoft Graph nebo prihlaseni k Entra ID selze, detail je v tele odpovedi.
- `503` kdyz sluzba nema nastaveny `MS365_CREDENTIAL_ENCODING_SECRET`.
- `403 text/plain` kdyz GET neprojde pres reverzni proxy (IP allowlist), tj. problem
neni ve sluzbe, ale v sitovem pristupu klienta.
Hlavicky lze vynechat jen tehdy, kdyz ma sluzba credentials nastavene
v environment promennych na serveru. Instance na services.csbot.cz je nema
(`GET /status` vraci `configured: false`), takze tam jsou hlavicky povinne.