first
This commit is contained in:
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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")
|
||||
Reference in New Issue
Block a user