This commit is contained in:
JiriUhlir
2026-05-27 10:23:25 +02:00
parent d85e60095f
commit 896657dd6f
8 changed files with 558 additions and 11 deletions
+26
View File
@@ -0,0 +1,26 @@
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
app_name: str = os.getenv("APP_NAME", "Microsoft 365 Service")
app_version: str = os.getenv("APP_VERSION", "1.0.0")
root_path: str = os.getenv("ROOT_PATH", "")
tenant_id: str = os.getenv("MS365_TENANT_ID", "")
client_id: str = os.getenv("MS365_CLIENT_ID", "")
client_secret: str = os.getenv("MS365_CLIENT_SECRET", "")
graph_base_url: str = os.getenv("MS365_GRAPH_BASE_URL", "https://graph.microsoft.com/v1.0")
graph_scope: str = os.getenv("MS365_GRAPH_SCOPE", "https://graph.microsoft.com/.default")
request_timeout_seconds: float = float(os.getenv("MS365_REQUEST_TIMEOUT_SECONDS", "30"))
@property
def token_url(self) -> str:
return f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token"
@property
def is_configured(self) -> bool:
return bool(self.tenant_id and self.client_id and self.client_secret)
settings = Settings()
+105
View File
@@ -0,0 +1,105 @@
import time
from typing import Any
import httpx
from fastapi import HTTPException, status
from .config import Settings
class MicrosoftGraphClient:
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._access_token: str | None = None
self._expires_at = 0.0
async def request(
self,
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
json: Any | None = None,
content: bytes | None = None,
headers: dict[str, str] | None = None,
) -> Any:
token = await self._get_access_token()
request_headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json",
}
if headers:
request_headers.update(headers)
url = self._build_url(path)
async with httpx.AsyncClient(timeout=self._settings.request_timeout_seconds) as client:
response = await client.request(
method,
url,
params=params,
json=json,
content=content,
headers=request_headers,
)
if response.status_code >= 400:
raise self._graph_exception(response)
if response.status_code == status.HTTP_204_NO_CONTENT or not response.content:
return None
return response.json()
async def _get_access_token(self) -> str:
if self._access_token and time.time() < self._expires_at - 60:
return self._access_token
if not self._settings.is_configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Microsoft 365 credentials are not configured.",
)
data = {
"client_id": self._settings.client_id,
"client_secret": self._settings.client_secret,
"scope": self._settings.graph_scope,
"grant_type": "client_credentials",
}
async with httpx.AsyncClient(timeout=self._settings.request_timeout_seconds) as client:
response = await client.post(self._settings.token_url, data=data)
if response.status_code >= 400:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail={
"message": "Unable to authenticate with Microsoft identity platform.",
"upstream_status": response.status_code,
"upstream_response": self._safe_json(response),
},
)
payload = response.json()
self._access_token = payload["access_token"]
self._expires_at = time.time() + int(payload.get("expires_in", 3599))
return self._access_token
def _build_url(self, path: str) -> str:
if path.startswith("https://"):
return path
return f"{self._settings.graph_base_url.rstrip('/')}/{path.lstrip('/')}"
def _graph_exception(self, response: httpx.Response) -> HTTPException:
return HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail={
"message": "Microsoft Graph request failed.",
"upstream_status": response.status_code,
"upstream_response": self._safe_json(response),
},
)
@staticmethod
def _safe_json(response: httpx.Response) -> Any:
try:
return response.json()
except ValueError:
return response.text
+9 -10
View File
@@ -1,15 +1,14 @@
import os
from fastapi import FastAPI
APP_NAME = os.getenv("APP_NAME", "Microsoft 365 Service")
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
ROOT_PATH = os.getenv("ROOT_PATH", "")
from .config import settings
from .routes import router as microsoft365_router
app = FastAPI(
title=APP_NAME,
version=APP_VERSION,
root_path=ROOT_PATH
title=settings.app_name,
version=settings.app_version,
root_path=settings.root_path
)
app.include_router(microsoft365_router)
@app.get("/health")
def health():
@@ -18,8 +17,8 @@ def health():
@app.get("/version")
def version():
return {
"app": APP_NAME,
"version": APP_VERSION,
"app": settings.app_name,
"version": settings.app_version,
"language": "python",
"root_path": ROOT_PATH
"root_path": settings.root_path
}
+131
View File
@@ -0,0 +1,131 @@
from typing import Any
from fastapi import APIRouter, Body, Depends, Query, Response, status
from .config import settings
from .graph_client import MicrosoftGraphClient
from .schemas import CalendarEventRequest, DriveUploadRequest, SendMailRequest
from .services import CalendarService, DriveService, GroupsService, MailService, UsersService
router = APIRouter(prefix="/microsoft365", tags=["microsoft365"])
graph_client = MicrosoftGraphClient(settings)
def get_graph_client() -> MicrosoftGraphClient:
return graph_client
def get_users_service(graph: MicrosoftGraphClient = Depends(get_graph_client)) -> UsersService:
return UsersService(graph)
def get_mail_service(graph: MicrosoftGraphClient = Depends(get_graph_client)) -> MailService:
return MailService(graph)
def get_calendar_service(graph: MicrosoftGraphClient = Depends(get_graph_client)) -> CalendarService:
return CalendarService(graph)
def get_drive_service(graph: MicrosoftGraphClient = Depends(get_graph_client)) -> DriveService:
return DriveService(graph)
def get_groups_service(graph: MicrosoftGraphClient = Depends(get_graph_client)) -> GroupsService:
return GroupsService(graph)
@router.get("/status")
def microsoft365_status() -> dict[str, Any]:
return {
"configured": settings.is_configured,
"tenant_id": bool(settings.tenant_id),
"client_id": bool(settings.client_id),
"client_secret": bool(settings.client_secret),
"graph_base_url": settings.graph_base_url,
}
@router.get("/users")
async def list_users(
top: int = Query(25, ge=1, le=999),
search: str | None = None,
service: UsersService = Depends(get_users_service),
) -> Any:
return await service.list_users(top=top, search=search)
@router.get("/users/{user_id}")
async def get_user(user_id: str, service: UsersService = Depends(get_users_service)) -> Any:
return await service.get_user(user_id)
@router.get("/users/{user_id}/mail/messages")
async def list_messages(
user_id: str,
folder: str = "Inbox",
top: int = Query(25, ge=1, le=100),
service: MailService = Depends(get_mail_service),
) -> Any:
return await service.list_messages(user_id=user_id, folder=folder, top=top)
@router.post("/users/{user_id}/mail/send", status_code=status.HTTP_202_ACCEPTED)
async def send_mail(
user_id: str,
request: SendMailRequest,
response: Response,
service: MailService = Depends(get_mail_service),
) -> None:
await service.send_mail(user_id=user_id, request=request)
response.status_code = status.HTTP_202_ACCEPTED
@router.get("/users/{user_id}/calendar/events")
async def list_events(
user_id: str,
top: int = Query(25, ge=1, le=100),
service: CalendarService = Depends(get_calendar_service),
) -> Any:
return await service.list_events(user_id=user_id, top=top)
@router.post("/users/{user_id}/calendar/events", status_code=status.HTTP_201_CREATED)
async def create_event(
user_id: str,
request: CalendarEventRequest,
service: CalendarService = Depends(get_calendar_service),
) -> Any:
return await service.create_event(user_id=user_id, request=request)
@router.get("/users/{user_id}/drive/root/children")
async def list_drive_root_children(
user_id: str,
top: int = Query(25, ge=1, le=200),
service: DriveService = Depends(get_drive_service),
) -> Any:
return await service.list_root_children(user_id=user_id, top=top)
@router.put("/users/{user_id}/drive/root/{path:path}")
async def upload_small_file(
user_id: str,
path: str,
request: DriveUploadRequest = Body(...),
service: DriveService = Depends(get_drive_service),
) -> Any:
return await service.upload_small_file(user_id=user_id, path=path, request=request)
@router.get("/groups")
async def list_groups(
top: int = Query(25, ge=1, le=999),
service: GroupsService = Depends(get_groups_service),
) -> Any:
return await service.list_groups(top=top)
@router.get("/teams/{team_id}/channels")
async def list_team_channels(team_id: str, service: GroupsService = Depends(get_groups_service)) -> Any:
return await service.list_team_channels(team_id=team_id)
+79
View File
@@ -0,0 +1,79 @@
from typing import Any
from pydantic import BaseModel, EmailStr, Field
class EmailAddress(BaseModel):
address: EmailStr
name: str | None = None
def as_graph_recipient(self) -> dict[str, Any]:
payload: dict[str, Any] = {"address": str(self.address)}
if self.name:
payload["name"] = self.name
return {"emailAddress": payload}
class FileAttachment(BaseModel):
name: str
content_type: str = "application/octet-stream"
content_bytes_base64: str = Field(..., description="Base64 encoded file content.")
def as_graph_attachment(self) -> dict[str, Any]:
return {
"@odata.type": "#microsoft.graph.fileAttachment",
"name": self.name,
"contentType": self.content_type,
"contentBytes": self.content_bytes_base64,
}
class SendMailRequest(BaseModel):
subject: str
body: str
to: list[EmailAddress]
cc: list[EmailAddress] = Field(default_factory=list)
bcc: list[EmailAddress] = Field(default_factory=list)
reply_to: list[EmailAddress] = Field(default_factory=list)
body_content_type: str = Field("HTML", pattern="^(HTML|Text)$")
save_to_sent_items: bool = True
attachments: list[FileAttachment] = Field(default_factory=list)
class CalendarAttendee(BaseModel):
email: EmailStr
name: str | None = None
type: str = Field("required", pattern="^(required|optional|resource)$")
def as_graph_attendee(self) -> dict[str, Any]:
email_address: dict[str, Any] = {"address": str(self.email)}
if self.name:
email_address["name"] = self.name
return {"emailAddress": email_address, "type": self.type}
class DateTimeTimeZone(BaseModel):
date_time: str = Field(..., alias="dateTime")
time_zone: str = Field("UTC", alias="timeZone")
class CalendarEventRequest(BaseModel):
subject: str
body: str | None = None
body_content_type: str = Field("HTML", pattern="^(HTML|Text)$")
start: DateTimeTimeZone
end: DateTimeTimeZone
location: str | None = None
attendees: list[CalendarAttendee] = Field(default_factory=list)
is_online_meeting: bool = False
online_meeting_provider: str | None = Field(None, pattern="^(teamsForBusiness|skypeForBusiness|skypeForConsumer)$")
class DriveUploadRequest(BaseModel):
content_base64: str
content_type: str = "application/octet-stream"
class GraphCollectionResponse(BaseModel):
value: list[dict[str, Any]]
next_link: str | None = Field(None, alias="@odata.nextLink")
+142
View File
@@ -0,0 +1,142 @@
import base64
import binascii
from typing import Any
from urllib.parse import quote
from fastapi import HTTPException, status
from .graph_client import MicrosoftGraphClient
from .schemas import CalendarEventRequest, DriveUploadRequest, SendMailRequest
def graph_segment(value: str) -> str:
return quote(value, safe="")
def graph_path(value: str) -> str:
return quote(value.strip("/"), safe="/")
class UsersService:
def __init__(self, graph: MicrosoftGraphClient) -> None:
self._graph = graph
async def list_users(self, top: int = 25, search: str | None = None) -> Any:
params: dict[str, Any] = {"$top": top}
headers = None
if search:
params["$search"] = f'"displayName:{search}" OR "mail:{search}" OR "userPrincipalName:{search}"'
headers = {"ConsistencyLevel": "eventual"}
return await self._graph.request("GET", "/users", params=params, headers=headers)
async def get_user(self, user_id: str) -> Any:
return await self._graph.request("GET", f"/users/{graph_segment(user_id)}")
class MailService:
def __init__(self, graph: MicrosoftGraphClient) -> None:
self._graph = graph
async def list_messages(self, user_id: str, folder: str = "Inbox", top: int = 25) -> Any:
params = {
"$top": top,
"$orderby": "receivedDateTime desc",
"$select": "id,subject,from,toRecipients,receivedDateTime,hasAttachments,isRead,webLink",
}
return await self._graph.request(
"GET",
f"/users/{graph_segment(user_id)}/mailFolders/{graph_segment(folder)}/messages",
params=params,
)
async def send_mail(self, user_id: str, request: SendMailRequest) -> None:
message: dict[str, Any] = {
"subject": request.subject,
"body": {
"contentType": request.body_content_type,
"content": request.body,
},
"toRecipients": [recipient.as_graph_recipient() for recipient in request.to],
}
if request.cc:
message["ccRecipients"] = [recipient.as_graph_recipient() for recipient in request.cc]
if request.bcc:
message["bccRecipients"] = [recipient.as_graph_recipient() for recipient in request.bcc]
if request.reply_to:
message["replyTo"] = [recipient.as_graph_recipient() for recipient in request.reply_to]
if request.attachments:
message["attachments"] = [attachment.as_graph_attachment() for attachment in request.attachments]
await self._graph.request(
"POST",
f"/users/{graph_segment(user_id)}/sendMail",
json={"message": message, "saveToSentItems": request.save_to_sent_items},
)
class CalendarService:
def __init__(self, graph: MicrosoftGraphClient) -> None:
self._graph = graph
async def list_events(self, user_id: str, top: int = 25) -> Any:
params = {
"$top": top,
"$orderby": "start/dateTime",
"$select": "id,subject,start,end,location,attendees,webLink,isOnlineMeeting,onlineMeeting",
}
return await self._graph.request("GET", f"/users/{graph_segment(user_id)}/events", params=params)
async def create_event(self, user_id: str, request: CalendarEventRequest) -> Any:
payload: dict[str, Any] = {
"subject": request.subject,
"start": request.start.model_dump(by_alias=True),
"end": request.end.model_dump(by_alias=True),
"attendees": [attendee.as_graph_attendee() for attendee in request.attendees],
"isOnlineMeeting": request.is_online_meeting,
}
if request.body is not None:
payload["body"] = {"contentType": request.body_content_type, "content": request.body}
if request.location:
payload["location"] = {"displayName": request.location}
if request.online_meeting_provider:
payload["onlineMeetingProvider"] = request.online_meeting_provider
return await self._graph.request("POST", f"/users/{graph_segment(user_id)}/events", json=payload)
class DriveService:
def __init__(self, graph: MicrosoftGraphClient) -> None:
self._graph = graph
async def list_root_children(self, user_id: str, top: int = 25) -> Any:
return await self._graph.request(
"GET",
f"/users/{graph_segment(user_id)}/drive/root/children",
params={"$top": top},
)
async def upload_small_file(self, user_id: str, path: str, request: DriveUploadRequest) -> Any:
try:
content = base64.b64decode(request.content_base64, validate=True)
except binascii.Error as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="content_base64 must contain valid base64 data.",
) from exc
return await self._graph.request(
"PUT",
f"/users/{graph_segment(user_id)}/drive/root:/{graph_path(path)}:/content",
content=content,
headers={"Content-Type": request.content_type},
)
class GroupsService:
def __init__(self, graph: MicrosoftGraphClient) -> None:
self._graph = graph
async def list_groups(self, top: int = 25) -> Any:
params = {"$top": top, "$select": "id,displayName,mail,groupTypes,securityEnabled,mailEnabled"}
return await self._graph.request("GET", "/groups", params=params)
async def list_team_channels(self, team_id: str) -> Any:
return await self._graph.request("GET", f"/teams/{graph_segment(team_id)}/channels")