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
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
@@ -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('/', '_');
|
||||||
|
}
|
||||||
+4
@@ -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.
|
||||||
|
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
502171e0ece4b0be631032bff07cea1c6b79151ad77b399268a548f2c009e1d0
|
||||||
+18
@@ -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;
|
||||||
Binary file not shown.
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user