commit 8d715a3a15e4331a75dd16320da23b304a5e1765 Author: youbin Date: Sat Mar 21 21:11:01 2026 +0800 Initial commit: Office365 web management platform diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..16ab1b4 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +APP_NAME=Office 365 User Management Platform +HOST=0.0.0.0 +PORT=8000 +DEBUG=false + +# Web login. If left empty, the management UI will run without login protection. +WEB_AUTH_ENABLED=true +ADMIN_USERNAME=admin +ADMIN_PASSWORD=ChangeMe123! +SESSION_SECRET=please-change-this-session-secret + +# Microsoft Graph app registration +CLIENT_ID= +TENANT_ID= +CLIENT_SECRET= + +# User defaults +DEFAULT_PASSWORD=ChangeMe!2026 +DEFAULT_DOMAIN=yourtenant.onmicrosoft.com +DEFAULT_USAGE_LOCATION=US +DEFAULT_LICENSE_SKU= +FORCE_CHANGE_PASSWORD=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc1caea --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.env +.DS_Store +__pycache__/ +*.pyc +*.pyo +*.pyd +.pycache/ +.pytest_cache/ +.venv/ +venv/ +dist/ +build/ +logs/ +reference-office365-tools/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb40174 --- /dev/null +++ b/README.md @@ -0,0 +1,250 @@ +# Office 365 Web 管理平台 + +基于你指定的 [`eggyrooch-blip/office365-tools`](https://github.com/eggyrooch-blip/office365-tools) 思路重新封装,做成了一套可直接 Web 管理的 Office 365 / Microsoft 365 账号管理后台。当前项目只保留国际版 Microsoft 365 / Microsoft Graph 实现。 + +当前版本覆盖的核心能力: + +- 单个账号增删改查 +- 用户列表勾选后批量删除、批量启用、批量停用、批量重置密码 +- 批量创建、批量更新、批量删除 +- 单个与批量重置密码 +- 批量模板 CSV 下载后上传执行 +- 批量任务进度展示,详细处理过程写入日志文件 +- 许可证 SKU 列表与剩余席位查看 +- 基于 `.env` 的租户配置 +- 可选的后台登录保护 + +## 项目结构 + +```text +. +├── app.py +├── office365_admin/ +│ ├── __init__.py +│ ├── batch.py +│ ├── graph.py +│ ├── routes.py +│ ├── services.py +│ ├── settings.py +│ ├── static/ +│ └── templates/ +├── tests/ +└── reference-office365-tools/ # 参考仓库,保留用于对照 +``` + +## 启动方式 + +1. 创建虚拟环境并安装依赖 + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +2. 复制配置文件 + +```bash +cp .env.example .env +``` + +3. 在 `.env` 中填写你的租户配置 + +必填项: + +- `CLIENT_ID` +- `TENANT_ID` +- `CLIENT_SECRET` +- `DEFAULT_PASSWORD` + +常用项: + +- `DEFAULT_DOMAIN` +- `DEFAULT_LICENSE_SKU` +- `ADMIN_USERNAME` +- `ADMIN_PASSWORD` + +4. 启动服务 + +```bash +python3 app.py +``` + +默认访问地址: + +- [http://127.0.0.1:8000](http://127.0.0.1:8000) + +## 批量导入格式 + +### 批量创建 CSV + +```csv +userPrincipalName,displayName,givenName,surname,department,jobTitle,usageLocation,skuPartNumber,password +alice,Alice Zhang,Alice,Zhang,Sales,Manager,US,ENTERPRISEPACK,Temp123! +bob@contoso.com,Bob Li,Bob,Li,IT,Engineer,US,ENTERPRISEPACK, +``` + +说明: + +- `userPrincipalName` 可以只写用户名,系统会自动拼接 `DEFAULT_DOMAIN` +- `password` 为空时使用 `DEFAULT_PASSWORD` +- `skuPartNumber` 为空时使用 `DEFAULT_LICENSE_SKU` + +### 批量更新 CSV + +```csv +userPrincipalName,department,jobTitle,accountEnabled,skuPartNumber +alice@contoso.com,Operations,Lead,true,O365_BUSINESS +bob@contoso.com,Finance,Analyst,false, +``` + +说明: + +- 至少要有一列账号标识 +- 批量更新里的空白字段默认忽略,不会清空已有值 + +### 批量删除文本 + +```text +alice +bob@contoso.com +charlie@contoso.com +``` + +### 批量重置密码 CSV + +```csv +userPrincipalName,password,forceChangePasswordNextSignIn +alice@contoso.com,Temp123!,true +bob@contoso.com,Another123!,true +``` + +也可以直接传纯文本账号列表,此时统一使用 `DEFAULT_PASSWORD`。 + +## 配置说明 + +### `.env` 参数配置与获取方式 + +| 参数名 | 是否必填 | 说明 | 获取方式 / 示例 | +| --- | --- | --- | --- | +| `CLIENT_ID` | 是 | Entra 应用的客户端 ID | 在应用注册概览页复制“应用程序(客户端) ID” | +| `TENANT_ID` | 是 | 租户 ID | 在应用注册概览页复制“目录(租户) ID” | +| `CLIENT_SECRET` | 是 | 应用客户端密钥值 | 在“证书和密码”中新建客户端密码后复制 `Value` | +| `DEFAULT_PASSWORD` | 是 | 新建账号或重置密码时的默认密码 | 由你自行定义,建议使用高强度临时密码 | +| `DEFAULT_DOMAIN` | 建议 | 用户名自动补全时使用的默认域名 | 例如 `yourtenant.onmicrosoft.com` 或已验证自定义域名 | +| `DEFAULT_USAGE_LOCATION` | 建议 | 账号默认使用地区 | 国际版常见示例:`US`、`SG`、`JP` | +| `DEFAULT_LICENSE_SKU` | 可选 | 创建用户时默认分配的许可证 SKU | 例如 `ENTERPRISEPACK`、`M365_BUSINESS_PREMIUM` | +| `WEB_AUTH_ENABLED` | 可选 | 是否启用平台网页登录保护 | `true` 或 `false` | +| `ADMIN_USERNAME` | 建议 | 本平台网页登录用户名 | 例如 `admin` | +| `ADMIN_PASSWORD` | 建议 | 本平台网页登录密码 | 由你自行定义;这不是 Microsoft 365 账号密码 | +| `SESSION_SECRET` | 建议 | Flask 会话密钥 | 建议使用随机长字符串 | +| `HOST` | 可选 | Web 服务监听地址 | 默认 `0.0.0.0` | +| `PORT` | 可选 | Web 服务监听端口 | 默认 `8000` | +| `DEBUG` | 可选 | 是否启用调试模式 | 默认 `false` | + +说明: + +- `DEFAULT_LICENSE_SKU` 需要填写租户里实际存在的 SKU Part Number;如果不确定,可以先启动系统,在“许可证概览”里查看。 +- `ADMIN_USERNAME` / `ADMIN_PASSWORD` 是这个管理平台自己的登录账号,不是 Microsoft 365 管理员邮箱账号。 +- 如果不填写 `DEFAULT_DOMAIN`,创建用户时必须填写完整邮箱格式的 `userPrincipalName`。 + +### Entra ID 应用注册配置 + +在使用本工具之前,需要在 Microsoft Entra ID(Azure AD)中注册应用程序并配置权限。 + +#### 步骤 1:创建应用注册 + +1. 登录 Microsoft Entra 管理中心 +2. 导航到 `身份` -> `应用注册` +3. 点击 `+ 新建注册` +4. 填写应用信息: + - 名称:输入应用名称,例如 `Office 365 用户管理工具` + - 支持的帐户类型:选择“仅此组织目录中的帐户” + - 重定向 URI:无需填写,本项目使用客户端凭据流程 +5. 点击 `注册` + +#### 步骤 2:获取应用信息 + +注册完成后,在应用概览页面可以获取: + +- 应用程序(客户端) ID:复制此值作为 `CLIENT_ID` +- 目录(租户) ID:复制此值作为 `TENANT_ID` + +#### 步骤 3:创建客户端密钥 + +1. 在应用注册页面,导航到 `证书和密码` +2. 点击 `+ 新建客户端密码` +3. 填写描述和过期时间 +4. 点击 `添加` +5. 重要:立即复制密钥值,它只会显示一次;将其填写为 `CLIENT_SECRET` + +#### 步骤 4:配置 API 权限 + +1. 在应用注册页面,导航到 `API 权限` +2. 点击 `+ 添加权限` -> `Microsoft Graph` -> `应用程序权限` + +必需权限列表: + +| 权限名称 | 说明 | 是否必需 | +| --- | --- | --- | +| `User.ReadWrite.All` | 读取和写入所有用户配置文件 | 必需,用于创建、更新、删除用户 | +| `User-PasswordProfile.ReadWrite.All` | 读取和写入所有用户的密码配置文件 | 必需,用于重置密码 | +| `LicenseAssignment.ReadWrite.All` | 读取和写入所有许可证分配 | 必需,用于分配许可证 | +| `Directory.ReadWrite.All` | 读取和写入目录数据 | 可选,更高权限,可替代部分用户写入权限 | +| `AuditLog.Read.All` | 读取审核日志数据 | 可选,用于后续扩展登录活动或审计场景 | + +权限配置步骤: + +1. 搜索并选择每个权限 +2. 点击 `添加权限` +3. 添加完成后,点击 `为 [你的组织名称] 授予管理员同意` +4. 确认授予同意 + +重要提示: + +- 所有应用程序权限都需要管理员同意才能生效。 +- 确保你是该应用程序的 `所有者`。 +- 权限生效可能需要几分钟。 +- 如果权限 Owner 发生变化,或者管理员重新授予了同意,建议重启本项目服务以重新获取访问令牌。 + +#### 步骤 5:验证权限配置 + +在 `API 权限` 页面,确认所有需要的权限状态都显示为“已授予(管理员同意)”。 + +### Graph 端点 + +项目固定使用国际版端点: + +- Graph API: `https://graph.microsoft.com/v1.0` +- Token Endpoint: `https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token` +- Scope: `https://graph.microsoft.com/.default` + +### 平台登录 + +如果你希望平台访问前需要登录,请配置: + +```env +WEB_AUTH_ENABLED=true +ADMIN_USERNAME=admin +ADMIN_PASSWORD=ChangeMe123! +SESSION_SECRET=random-long-secret +``` + +如果 `WEB_AUTH_ENABLED=true` 但没有设置用户名密码,系统会自动降级为无登录保护模式,并在页面状态区显示提醒。 + +## 测试 + +```bash +pytest +``` + +## 日志 + +服务运行后会把详细操作日志写入: + +- [logs/office365_admin.log](/Users/youbin/Desktop/Office365UserManage/logs/office365_admin.log) + +## 说明 + +- `reference-office365-tools` 是我拉下来的参考仓库,当前运行时不会直接引用它。 +- Web 平台的 Graph 调用逻辑已经按后台服务方式重构,便于继续扩展审批流、组织架构、日志审计等能力。 diff --git a/app.py b/app.py new file mode 100644 index 0000000..b8f50f2 --- /dev/null +++ b/app.py @@ -0,0 +1,12 @@ +from office365_admin import create_app + +app = create_app() + + +if __name__ == "__main__": + app.run( + host=app.config["SETTINGS"].host, + port=app.config["SETTINGS"].port, + debug=app.config["SETTINGS"].debug, + ) + diff --git a/office365_admin/__init__.py b/office365_admin/__init__.py new file mode 100644 index 0000000..2f18474 --- /dev/null +++ b/office365_admin/__init__.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import logging +from datetime import timedelta +from logging.handlers import RotatingFileHandler +from pathlib import Path + +from flask import Flask + +from .services import Office365Service +from .settings import Settings, load_settings +from .tasks import BackgroundTaskManager + + +def _configure_logging(app: Flask) -> None: + log_dir = Path(app.root_path).parent / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / "office365_admin.log" + + root_logger = logging.getLogger() + formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s - %(message)s") + + if not any(isinstance(handler, RotatingFileHandler) for handler in root_logger.handlers): + file_handler = RotatingFileHandler( + log_path, + maxBytes=2 * 1024 * 1024, + backupCount=5, + encoding="utf-8", + ) + file_handler.setFormatter(formatter) + root_logger.addHandler(file_handler) + + root_logger.setLevel(logging.INFO) + app.logger.info("Logging initialized at %s", log_path) + + +def create_app( + settings_override: Settings | None = None, + service_factory=None, + task_manager_factory=None, +) -> Flask: + settings = settings_override or load_settings() + app = Flask(__name__, template_folder="templates", static_folder="static") + app.config["SETTINGS"] = settings + app.config["JSON_AS_ASCII"] = False + app.secret_key = settings.session_secret + app.permanent_session_lifetime = timedelta(hours=8) + _configure_logging(app) + + if service_factory is None: + service = Office365Service(settings) + elif callable(service_factory): + service = service_factory(settings) + else: + service = service_factory + + app.extensions["office365_service"] = service + if task_manager_factory is None: + task_manager = BackgroundTaskManager() + elif callable(task_manager_factory): + task_manager = task_manager_factory() + else: + task_manager = task_manager_factory + app.extensions["task_manager"] = task_manager + + from .routes import bp + + app.register_blueprint(bp) + return app diff --git a/office365_admin/batch.py b/office365_admin/batch.py new file mode 100644 index 0000000..d006c23 --- /dev/null +++ b/office365_admin/batch.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import csv +import io +import json +from typing import Any + + +IDENTIFIER_KEYS = ( + "userprincipalname", + "user_id", + "userid", + "username", + "email", + "mail", + "id", + "upn", +) + + +class BatchInputError(ValueError): + pass + + +def _strip_bom(raw_text: str) -> str: + return raw_text.lstrip("\ufeff").strip() + + +def _clean_dict(row: dict[str, Any]) -> dict[str, Any]: + cleaned: dict[str, Any] = {} + for key, value in row.items(): + normalized_key = str(key).strip() + if not normalized_key: + continue + cleaned[normalized_key] = value.strip() if isinstance(value, str) else value + return cleaned + + +def parse_table_content(raw_text: str) -> list[dict[str, Any]]: + raw_text = _strip_bom(raw_text) + if not raw_text: + raise BatchInputError("批量内容不能为空。") + + try: + payload = json.loads(raw_text) + except json.JSONDecodeError: + payload = None + + if payload is not None: + if not isinstance(payload, list): + raise BatchInputError("JSON 批量内容必须是数组。") + rows: list[dict[str, Any]] = [] + for index, item in enumerate(payload, start=1): + if not isinstance(item, dict): + raise BatchInputError(f"第 {index} 条 JSON 记录不是对象。") + rows.append(_clean_dict(item)) + if not rows: + raise BatchInputError("批量内容中没有可用记录。") + return rows + + try: + reader = csv.DictReader(io.StringIO(raw_text)) + except csv.Error as exc: + raise BatchInputError(f"CSV 解析失败: {exc}") from exc + + if not reader.fieldnames: + raise BatchInputError("CSV 缺少表头。") + + rows = [] + for row in reader: + if not row: + continue + if not any((value or "").strip() for value in row.values()): + continue + rows.append(_clean_dict(row)) + + if not rows: + raise BatchInputError("未解析到任何批量记录。") + return rows + + +def parse_identifier_content(raw_text: str) -> list[str]: + raw_text = _strip_bom(raw_text) + if not raw_text: + raise BatchInputError("批量内容不能为空。") + + try: + payload = json.loads(raw_text) + except json.JSONDecodeError: + payload = None + + if payload is not None: + if not isinstance(payload, list): + raise BatchInputError("JSON 批量删除内容必须是数组。") + values: list[str] = [] + for item in payload: + if isinstance(item, str) and item.strip(): + values.append(item.strip()) + elif isinstance(item, dict): + identifier = extract_identifier(item) + if identifier: + values.append(identifier) + if not values: + raise BatchInputError("JSON 内容中没有可用的账号标识。") + return values + + first_line = raw_text.splitlines()[0] if raw_text.splitlines() else "" + if "," in first_line: + try: + reader = csv.DictReader(io.StringIO(raw_text)) + if reader.fieldnames: + values = [] + for row in reader: + identifier = extract_identifier(_clean_dict(row)) + if identifier: + values.append(identifier) + if values: + return values + except csv.Error: + pass + + raw_text = raw_text.replace(",", "\n") + values = [] + for line in raw_text.splitlines(): + normalized = line.strip() + if normalized and not normalized.startswith("#"): + values.append(normalized) + + if not values: + raise BatchInputError("没有找到可用的账号标识。") + return values + + +def extract_identifier(row: dict[str, Any]) -> str: + key_map = {normalize_key(key): value for key, value in row.items()} + for key in IDENTIFIER_KEYS: + value = key_map.get(normalize_key(key)) + if value is None: + continue + if isinstance(value, str) and value.strip(): + return value.strip() + if value: + return str(value) + return "" + + +def normalize_key(key: str) -> str: + return "".join(ch for ch in key.lower() if ch.isalnum()) + diff --git a/office365_admin/graph.py b/office365_admin/graph.py new file mode 100644 index 0000000..d0b787d --- /dev/null +++ b/office365_admin/graph.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import time +from typing import Any +from urllib.parse import quote + +import requests + + +REQUEST_TIMEOUT = 30 + + +class TokenError(RuntimeError): + pass + + +class GraphAPIError(RuntimeError): + def __init__(self, message: str, status_code: int | None = None, response: Any = None): + super().__init__(message) + self.message = message + self.status_code = status_code + self.response = response + + +class TokenManager: + def __init__(self, client_id: str, client_secret: str, token_endpoint: str, scope: str): + self.client_id = client_id + self.client_secret = client_secret + self.token_endpoint = token_endpoint + self.scope = scope + self._token = "" + self._expires_at = 0 + + def get_access_token(self) -> str: + now = int(time.time()) + if self._token and now < self._expires_at: + return self._token + + data = { + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + "scope": self.scope, + } + + try: + response = requests.post( + self.token_endpoint, + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=REQUEST_TIMEOUT, + ) + response.raise_for_status() + except requests.RequestException as exc: + message = f"获取访问令牌失败: {exc}" + raise TokenError(message) from exc + + payload = response.json() + token = payload.get("access_token") + if not token: + raise TokenError("访问令牌响应缺少 access_token。") + + expires_in = int(payload.get("expires_in", 3600)) + self._token = token + self._expires_at = now + max(expires_in - 300, 60) + return token + + +class GraphClient: + def __init__(self, token_manager: TokenManager, base_url: str): + self.token_manager = token_manager + self.base_url = base_url.rstrip("/") + self.session = requests.Session() + + def _headers(self, extra_headers: dict[str, str] | None = None) -> dict[str, str]: + headers = { + "Authorization": f"Bearer {self.token_manager.get_access_token()}", + "Content-Type": "application/json", + } + if extra_headers: + headers.update(extra_headers) + return headers + + def _request( + self, + method: str, + endpoint: str, + *, + params: dict[str, Any] | None = None, + json_data: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, + absolute_url: bool = False, + ) -> dict[str, Any]: + url = endpoint if absolute_url else f"{self.base_url}/{endpoint.lstrip('/')}" + try: + response = self.session.request( + method=method.upper(), + url=url, + params=params, + json=json_data, + headers=self._headers(extra_headers), + timeout=REQUEST_TIMEOUT, + ) + except requests.RequestException as exc: + raise GraphAPIError(f"调用 Microsoft Graph 失败: {exc}") from exc + + if response.status_code == 204: + return {} + + if not response.ok: + details: Any + try: + details = response.json() + except ValueError: + details = {"error": response.text} + message = self._extract_error_message(details) or f"Graph API 返回 HTTP {response.status_code}" + raise GraphAPIError(message, status_code=response.status_code, response=details) + + if not response.content: + return {} + return response.json() + + @staticmethod + def _extract_error_message(details: Any) -> str: + if isinstance(details, dict): + graph_error = details.get("error") + if isinstance(graph_error, dict): + return str(graph_error.get("message") or graph_error.get("code") or "") + if graph_error: + return str(graph_error) + if "message" in details: + return str(details["message"]) + return "" + + @staticmethod + def _quote_identifier(identifier: str) -> str: + return quote(str(identifier), safe="@._-$") + + def create_user(self, payload: dict[str, Any]) -> dict[str, Any]: + return self._request("POST", "/users", json_data=payload) + + def get_user(self, identifier: str, select_fields: list[str] | None = None) -> dict[str, Any]: + params = {} + if select_fields: + params["$select"] = ",".join(select_fields) + return self._request("GET", f"/users/{self._quote_identifier(identifier)}", params=params or None) + + def update_user(self, identifier: str, payload: dict[str, Any]) -> dict[str, Any]: + return self._request("PATCH", f"/users/{self._quote_identifier(identifier)}", json_data=payload) + + def delete_user(self, identifier: str) -> None: + self._request("DELETE", f"/users/{self._quote_identifier(identifier)}") + + def list_users(self, select_fields: list[str] | None = None) -> list[dict[str, Any]]: + params = {} + if select_fields: + params["$select"] = ",".join(select_fields) + + response = self._request("GET", "/users", params=params or None) + users = list(response.get("value", [])) + next_link = response.get("@odata.nextLink") + + while next_link: + response = self._request("GET", next_link, absolute_url=True) + users.extend(response.get("value", [])) + next_link = response.get("@odata.nextLink") + + return users + + def list_subscribed_skus(self) -> list[dict[str, Any]]: + return self._request("GET", "/subscribedSkus").get("value", []) + + def assign_license( + self, + user_id: str, + *, + add_licenses: list[dict[str, Any]], + remove_licenses: list[str] | None = None, + ) -> dict[str, Any]: + payload = { + "addLicenses": add_licenses, + "removeLicenses": remove_licenses or [], + } + return self._request( + "POST", + f"/users/{self._quote_identifier(user_id)}/assignLicense", + json_data=payload, + ) + diff --git a/office365_admin/routes.py b/office365_admin/routes.py new file mode 100644 index 0000000..3b1681e --- /dev/null +++ b/office365_admin/routes.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +from functools import wraps +import logging + +from flask import Blueprint, current_app, jsonify, render_template, request, session + +from .batch import BatchInputError, parse_identifier_content, parse_table_content +from .services import Office365Service, ServiceConfigurationError, ServiceOperationError +from .tasks import BackgroundTaskManager, TaskNotFoundError + + +bp = Blueprint("office365_admin", __name__) +logger = logging.getLogger("office365_admin.routes") + + +def _settings(): + return current_app.config["SETTINGS"] + + +def _service() -> Office365Service: + return current_app.extensions["office365_service"] + + +def _task_manager() -> BackgroundTaskManager: + return current_app.extensions["task_manager"] + + +def _success(data=None, message: str = "ok", status: int = 200): + return jsonify({"success": True, "message": message, "data": data}), status + + +def _error(message: str, status: int = 400, details=None): + payload = {"success": False, "message": message} + if details is not None: + payload["details"] = details + return jsonify(payload), status + + +def _authenticated() -> bool: + settings = _settings() + if not settings.effective_auth_enabled: + return True + return bool(session.get("authenticated")) + + +def require_auth(view_func): + @wraps(view_func) + def wrapped(*args, **kwargs): + if not _authenticated(): + return _error("请先登录后台管理平台。", status=401) + return view_func(*args, **kwargs) + + return wrapped + + +def _handle_service_call(callback): + try: + return callback() + except BatchInputError as exc: + return _error(str(exc), status=400) + except ServiceConfigurationError as exc: + return _error(str(exc), status=503) + except ServiceOperationError as exc: + return _error(exc.message, status=exc.status_code, details=exc.details) + except ValueError as exc: + return _error(str(exc), status=400) + + +def _json_payload() -> dict: + return request.get_json(silent=True) or {} + + +def _text_payload() -> str: + payload = _json_payload() + if payload.get("content"): + return str(payload["content"]) + + if request.form.get("content"): + return request.form["content"] + + uploaded_file = request.files.get("file") + if uploaded_file and uploaded_file.filename: + return uploaded_file.read().decode("utf-8-sig") + + return "" + + +@bp.get("/") +def index(): + return render_template("index.html", bootstrap=_settings().to_public_dict()) + + +@bp.get("/api/health") +def health(): + settings = _settings() + return _success( + { + "platform": settings.to_public_dict(), + "authenticated": _authenticated(), + } + ) + + +@bp.get("/api/session") +def session_info(): + return _success( + { + "authenticated": _authenticated(), + "authEnabled": _settings().effective_auth_enabled, + } + ) + + +@bp.post("/api/login") +def login(): + settings = _settings() + if not settings.effective_auth_enabled: + session["authenticated"] = True + session.permanent = True + return _success({"authenticated": True}, message="当前平台未启用登录保护。") + + payload = _json_payload() + username = str(payload.get("username", "")).strip() + password = str(payload.get("password", "")).strip() + + if username == settings.admin_username and password == settings.admin_password: + session["authenticated"] = True + session.permanent = True + return _success({"authenticated": True}, message="登录成功。") + return _error("用户名或密码错误。", status=401) + + +@bp.post("/api/logout") +def logout(): + session.clear() + return _success({"authenticated": False}, message="已退出登录。") + + +@bp.get("/api/config") +@require_auth +def config_info(): + return _success(_settings().to_public_dict()) + + +@bp.get("/api/tasks/") +@require_auth +def task_status(task_id: str): + try: + return _success(_task_manager().get_task(task_id)) + except TaskNotFoundError: + return _error("任务不存在。", status=404) + + +@bp.get("/api/licenses") +@require_auth +def licenses(): + return _handle_service_call(lambda: _success(_service().list_licenses())) + + +@bp.get("/api/users") +@require_auth +def list_users(): + search = request.args.get("search", "").strip() + try: + page = int(request.args.get("page", "1")) + page_size = int(request.args.get("pageSize", str(_settings().default_page_size))) + except ValueError: + return _error("page 和 pageSize 必须是整数。", status=400) + + return _handle_service_call( + lambda: _success(_service().list_users(search=search, page=page, page_size=page_size)) + ) + + +@bp.get("/api/users/selection") +@require_auth +def list_user_identifiers(): + search = request.args.get("search", "").strip() + return _handle_service_call( + lambda: _success(_service().list_user_identifiers(search=search)) + ) + + +@bp.get("/api/users/") +@require_auth +def get_user(identifier: str): + return _handle_service_call(lambda: _success(_service().get_user(identifier))) + + +@bp.post("/api/users") +@require_auth +def create_user(): + payload = _json_payload() + return _handle_service_call( + lambda: _success(_service().create_user(payload), message="用户创建成功。", status=201) + ) + + +@bp.patch("/api/users/") +@require_auth +def update_user(identifier: str): + payload = _json_payload() + return _handle_service_call( + lambda: _success(_service().update_user(identifier, payload), message="用户更新成功。") + ) + + +@bp.delete("/api/users/") +@require_auth +def delete_user(identifier: str): + return _handle_service_call( + lambda: _success(_service().delete_user(identifier), message="用户删除成功。") + ) + + +@bp.post("/api/users//reset-password") +@require_auth +def reset_password(identifier: str): + payload = _json_payload() + return _handle_service_call( + lambda: _success(_service().reset_password(identifier, payload), message="密码重置成功。") + ) + + +@bp.post("/api/users/batch/create") +@require_auth +def batch_create(): + payload = _json_payload() + rows = payload.get("rows") + if rows is None: + rows = parse_table_content(_text_payload()) + return _handle_service_call(lambda: _submit_batch_task("create", rows)) + + +@bp.post("/api/users/batch/update") +@require_auth +def batch_update(): + payload = _json_payload() + rows = payload.get("rows") + if rows is None: + rows = parse_table_content(_text_payload()) + return _handle_service_call(lambda: _submit_batch_task("update", rows)) + + +@bp.post("/api/users/batch/delete") +@require_auth +def batch_delete(): + payload = _json_payload() + identifiers = payload.get("identifiers") + if identifiers is None: + identifiers = parse_identifier_content(_text_payload()) + return _handle_service_call(lambda: _submit_batch_task("delete", identifiers)) + + +@bp.post("/api/users/batch/reset-password") +@require_auth +def batch_reset_password(): + payload = _json_payload() + rows = payload.get("rows") + if rows is None: + text = _text_payload() + try: + rows = parse_table_content(text) + except BatchInputError: + rows = parse_identifier_content(text) + return _handle_service_call(lambda: _submit_batch_task("reset-password", rows)) + + +def _submit_batch_task(operation: str, items): + service = _service() + task_manager = _task_manager() + total = len(items) + if operation == "create": + runner = lambda progress: service.batch_create(items, progress_callback=progress) + message = "批量创建任务已提交。" + elif operation == "update": + runner = lambda progress: service.batch_update(items, progress_callback=progress) + message = "批量更新任务已提交。" + elif operation == "delete": + runner = lambda progress: service.batch_delete(items, progress_callback=progress) + message = "批量删除任务已提交。" + elif operation == "reset-password": + runner = lambda progress: service.batch_reset_password(items, progress_callback=progress) + message = "批量重置密码任务已提交。" + else: + raise ValueError("不支持的批量任务类型。") + + logger.info("Submitting batch task: operation=%s total=%s", operation, total) + task = task_manager.submit(operation=operation, total=total, runner=runner) + return _success(task, message=message, status=202) diff --git a/office365_admin/services.py b/office365_admin/services.py new file mode 100644 index 0000000..99f1753 --- /dev/null +++ b/office365_admin/services.py @@ -0,0 +1,597 @@ +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import Any + +from .graph import GraphAPIError, GraphClient, TokenManager +from .settings import Settings + + +logger = logging.getLogger("office365_admin.service") + + +USER_SELECT_FIELDS = [ + "id", + "displayName", + "userPrincipalName", + "mail", + "givenName", + "surname", + "department", + "jobTitle", + "officeLocation", + "mobilePhone", + "usageLocation", + "accountEnabled", + "assignedLicenses", + "createdDateTime", +] + + +IDENTIFIER_ALIASES = [ + "userPrincipalName", + "user_principal_name", + "user_id", + "userId", + "username", + "email", + "mail", + "upn", + "id", +] + + +OPTIONAL_FIELD_ALIASES = { + "displayName": ["displayName", "display_name"], + "mailNickname": ["mailNickname", "mail_nickname", "nickname"], + "givenName": ["givenName", "given_name", "firstName", "firstname"], + "surname": ["surname", "lastName", "lastname", "last_name"], + "department": ["department"], + "jobTitle": ["jobTitle", "job_title"], + "officeLocation": ["officeLocation", "office_location"], + "mobilePhone": ["mobilePhone", "mobile", "phone"], + "usageLocation": ["usageLocation", "usage_location"], + "userPrincipalName": ["userPrincipalName", "user_principal_name", "upn"], +} + + +NULLABLE_FIELDS = { + "displayName", + "givenName", + "surname", + "department", + "jobTitle", + "officeLocation", + "mobilePhone", +} + + +@dataclass +class ServiceOperationError(RuntimeError): + message: str + status_code: int = 400 + details: Any = None + + def __str__(self) -> str: + return self.message + + +class ServiceConfigurationError(RuntimeError): + pass + + +class Office365Service: + def __init__(self, settings: Settings): + self.settings = settings + self._graph_client: GraphClient | None = None + + def status(self) -> dict[str, Any]: + return { + "ready": self.settings.graph_ready, + "validationErrors": list(self.settings.validation_errors), + "warnings": list(self.settings.warnings), + "graphFlavor": "Microsoft Graph Global", + } + + def _ensure_client(self) -> GraphClient: + if not self.settings.graph_ready: + joined = ";".join(self.settings.validation_errors) + raise ServiceConfigurationError(f"Graph 配置不完整: {joined}") + + if self._graph_client is None: + token_manager = TokenManager( + client_id=self.settings.client_id, + client_secret=self.settings.client_secret, + token_endpoint=self.settings.token_endpoint, + scope=self.settings.scope, + ) + self._graph_client = GraphClient(token_manager, self.settings.graph_base_url) + return self._graph_client + + def list_licenses(self) -> list[dict[str, Any]]: + client = self._ensure_client() + try: + skus = client.list_subscribed_skus() + except GraphAPIError as exc: + raise self._translate_graph_error(exc, "读取许可证列表失败") + + items = [] + for sku in skus: + total = int(sku.get("prepaidUnits", {}).get("enabled", 0) or 0) + consumed = int(sku.get("consumedUnits", 0) or 0) + items.append( + { + "skuId": sku.get("skuId"), + "skuPartNumber": sku.get("skuPartNumber"), + "availableUnits": max(total - consumed, 0), + "totalUnits": total, + "consumedUnits": consumed, + } + ) + return sorted(items, key=lambda item: item["skuPartNumber"] or "") + + def list_users(self, search: str = "", page: int = 1, page_size: int | None = None) -> dict[str, Any]: + requested_page_size = page_size or self.settings.default_page_size + requested_page_size = min(max(requested_page_size, 1), self.settings.max_page_size) + page = max(page, 1) + users, total_before_search = self._list_filtered_users(search) + + total = len(users) + start = (page - 1) * requested_page_size + end = start + requested_page_size + paged_users = users[start:end] + + return { + "items": paged_users, + "page": page, + "pageSize": requested_page_size, + "total": total, + "totalBeforeSearch": total_before_search, + "summary": { + "active": sum(1 for user in users if user["accountEnabled"]), + "disabled": sum(1 for user in users if not user["accountEnabled"]), + }, + } + + def list_user_identifiers(self, search: str = "") -> dict[str, Any]: + users, _ = self._list_filtered_users(search) + identifiers = [ + user["userPrincipalName"] + for user in users + if user.get("userPrincipalName") + ] + return { + "identifiers": identifiers, + "total": len(identifiers), + } + + def get_user(self, identifier: str) -> dict[str, Any]: + client = self._ensure_client() + identifier = self._normalize_identifier(identifier) + sku_lookup = self._get_sku_lookup() + + try: + user = client.get_user(identifier, USER_SELECT_FIELDS) + except GraphAPIError as exc: + raise self._translate_graph_error(exc, f"读取用户 {identifier} 失败") + + serialized = self._serialize_user(user, sku_lookup=sku_lookup) + serialized["licenses"] = [ + { + "skuId": sku_id, + "skuPartNumber": sku_lookup.get(sku_id, sku_id), + } + for sku_id in serialized["assignedLicenses"] + ] + return serialized + + def create_user(self, payload: dict[str, Any]) -> dict[str, Any]: + client = self._ensure_client() + identifier = self._resolve_identifier(payload, required=True) + upn = self._normalize_identifier(identifier) + username = upn.split("@", 1)[0] + + password = self._string_value(payload, ["password"]) or self.settings.default_password + force_change_password = self._bool_value( + payload, + ["forceChangePasswordNextSignIn", "force_change_password"], + self.settings.force_change_password, + ) + account_enabled = self._bool_value(payload, ["accountEnabled", "enabled"], True) + + create_payload = { + "accountEnabled": account_enabled, + "displayName": self._string_value(payload, OPTIONAL_FIELD_ALIASES["displayName"]) or username, + "mailNickname": self._string_value(payload, OPTIONAL_FIELD_ALIASES["mailNickname"]) or username, + "userPrincipalName": upn, + "passwordProfile": { + "password": password, + "forceChangePasswordNextSignIn": force_change_password, + }, + } + + for graph_field, aliases in OPTIONAL_FIELD_ALIASES.items(): + if graph_field in {"displayName", "mailNickname", "userPrincipalName"}: + continue + value = self._string_value(payload, aliases) + if value: + create_payload[graph_field] = value + + if "usageLocation" not in create_payload: + create_payload["usageLocation"] = self.settings.default_usage_location + + try: + user = client.create_user(create_payload) + except GraphAPIError as exc: + raise self._translate_graph_error(exc, f"创建用户 {upn} 失败") + + license_result = None + sku_part_number = self._string_value(payload, ["skuPartNumber", "sku", "license"]) or self.settings.default_license_sku + if sku_part_number: + license_result = self._assign_license(user["id"], sku_part_number) + + return { + "user": self.get_user(user["id"]), + "temporaryPassword": password, + "licenseAssigned": bool(license_result), + "licenseResult": license_result, + } + + def update_user( + self, + identifier: str, + payload: dict[str, Any], + *, + blank_strategy: str = "clear", + ) -> dict[str, Any]: + client = self._ensure_client() + identifier = self._normalize_identifier(identifier) + + patch_payload: dict[str, Any] = {} + for graph_field, aliases in OPTIONAL_FIELD_ALIASES.items(): + value = self._raw_value(payload, aliases) + if value is None: + continue + if isinstance(value, str): + value = value.strip() + + if value == "" and graph_field in NULLABLE_FIELDS: + if blank_strategy == "clear": + patch_payload[graph_field] = None + continue + + if value != "": + patch_payload[graph_field] = value + + if self._raw_value(payload, ["accountEnabled", "enabled"]) is not None: + patch_payload["accountEnabled"] = self._bool_value(payload, ["accountEnabled", "enabled"], True) + + password = self._string_value(payload, ["password"]) + if password: + patch_payload["passwordProfile"] = { + "password": password, + "forceChangePasswordNextSignIn": self._bool_value( + payload, + ["forceChangePasswordNextSignIn", "force_change_password"], + self.settings.force_change_password, + ), + } + + if patch_payload: + try: + client.update_user(identifier, patch_payload) + except GraphAPIError as exc: + raise self._translate_graph_error(exc, f"更新用户 {identifier} 失败") + + license_result = None + sku_part_number = self._string_value(payload, ["skuPartNumber", "sku", "license"]) + if sku_part_number: + existing_user = self.get_user(identifier) + license_result = self._assign_license(existing_user["id"], sku_part_number) + + updated_identifier = patch_payload.get("userPrincipalName", identifier) + return { + "user": self.get_user(updated_identifier), + "licenseAssigned": bool(license_result), + "licenseResult": license_result, + } + + def delete_user(self, identifier: str) -> dict[str, Any]: + client = self._ensure_client() + identifier = self._normalize_identifier(identifier) + try: + user = self.get_user(identifier) + client.delete_user(identifier) + except GraphAPIError as exc: + raise self._translate_graph_error(exc, f"删除用户 {identifier} 失败") + return {"user": user} + + def reset_password(self, identifier: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + client = self._ensure_client() + identifier = self._normalize_identifier(identifier) + payload = payload or {} + password = self._string_value(payload, ["password"]) or self.settings.default_password + force_change_password = self._bool_value( + payload, + ["forceChangePasswordNextSignIn", "force_change_password"], + self.settings.force_change_password, + ) + reset_payload = { + "passwordProfile": { + "password": password, + "forceChangePasswordNextSignIn": force_change_password, + } + } + try: + client.update_user(identifier, reset_payload) + except GraphAPIError as exc: + raise self._translate_graph_error(exc, f"重置用户 {identifier} 密码失败") + return { + "user": self.get_user(identifier), + "temporaryPassword": password, + } + + def batch_create(self, rows: list[dict[str, Any]], progress_callback=None) -> dict[str, Any]: + return self._run_batch( + operation="create", + items=rows, + callback=lambda row: self.create_user(row), + identifier_getter=lambda row: self._resolve_identifier(row, required=False), + progress_callback=progress_callback, + ) + + def batch_update(self, rows: list[dict[str, Any]], progress_callback=None) -> dict[str, Any]: + return self._run_batch( + operation="update", + items=rows, + callback=lambda row: self.update_user( + self._resolve_identifier(row, required=True), + row, + blank_strategy="ignore", + ), + identifier_getter=lambda row: self._resolve_identifier(row, required=False), + progress_callback=progress_callback, + ) + + def batch_delete(self, identifiers: list[str], progress_callback=None) -> dict[str, Any]: + return self._run_batch( + operation="delete", + items=identifiers, + callback=lambda identifier: self.delete_user(identifier), + identifier_getter=lambda identifier: identifier, + progress_callback=progress_callback, + ) + + def batch_reset_password(self, rows: list[dict[str, Any]] | list[str], progress_callback=None) -> dict[str, Any]: + return self._run_batch( + operation="reset-password", + items=rows, + callback=self._batch_reset_callback, + identifier_getter=self._batch_reset_identifier, + progress_callback=progress_callback, + ) + + def _batch_reset_callback(self, item: dict[str, Any] | str) -> dict[str, Any]: + if isinstance(item, str): + return self.reset_password(item) + identifier = self._resolve_identifier(item, required=True) + return self.reset_password(identifier, item) + + def _batch_reset_identifier(self, item: dict[str, Any] | str) -> str: + if isinstance(item, str): + return item + return self._resolve_identifier(item, required=False) + + def _run_batch(self, operation: str, items: list[Any], callback, identifier_getter, progress_callback=None) -> dict[str, Any]: + results = [] + success_count = 0 + logger.info("Batch %s started: total=%s", operation, len(items)) + + for index, item in enumerate(items, start=1): + identifier = identifier_getter(item) or f"item-{index}" + try: + result = callback(item) + success_count += 1 + record = { + "index": index, + "identifier": identifier, + "success": True, + "message": "执行成功", + "data": result, + } + logger.info("Batch %s item success: %s", operation, identifier) + except (ServiceConfigurationError, ServiceOperationError, ValueError) as exc: + record = { + "index": index, + "identifier": identifier, + "success": False, + "message": str(exc), + } + logger.warning("Batch %s item failed: %s - %s", operation, identifier, exc) + except Exception as exc: + record = { + "index": index, + "identifier": identifier, + "success": False, + "message": str(exc), + } + logger.exception("Batch %s item crashed: %s", operation, identifier) + + results.append(record) + if progress_callback: + progress_callback( + { + "completed": index, + "total": len(items), + "successCount": success_count, + "failureCount": index - success_count, + "identifier": identifier, + "success": record["success"], + "message": record["message"], + } + ) + + summary = { + "operation": operation, + "total": len(items), + "successCount": success_count, + "failureCount": len(items) - success_count, + "results": results, + } + logger.info( + "Batch %s finished: total=%s success=%s failure=%s", + operation, + summary["total"], + summary["successCount"], + summary["failureCount"], + ) + return summary + + def _assign_license(self, user_id: str, sku_part_number: str) -> dict[str, Any]: + client = self._ensure_client() + skus = self.list_licenses() + matched = next( + (sku for sku in skus if (sku["skuPartNumber"] or "").upper() == sku_part_number.upper()), + None, + ) + if not matched: + raise ServiceOperationError(f"未找到许可证 SKU: {sku_part_number}", status_code=404) + if matched["availableUnits"] <= 0: + raise ServiceOperationError(f"许可证 {sku_part_number} 已无可用席位。", status_code=409) + + try: + return client.assign_license( + user_id, + add_licenses=[{"skuId": matched["skuId"], "disabledPlans": []}], + ) + except GraphAPIError as exc: + raise self._translate_graph_error(exc, f"为用户分配许可证 {sku_part_number} 失败") + + def _get_sku_lookup(self) -> dict[str, str]: + return { + item["skuId"]: item["skuPartNumber"] + for item in self.list_licenses() + if item.get("skuId") + } + + def _list_filtered_users(self, search: str = "") -> tuple[list[dict[str, Any]], int]: + client = self._ensure_client() + try: + raw_users = client.list_users(USER_SELECT_FIELDS) + except GraphAPIError as exc: + raise self._translate_graph_error(exc, "读取用户列表失败") + + users = [self._serialize_user(user) for user in raw_users] + total_before_search = len(users) + if search.strip(): + query = search.strip().lower() + users = [ + user + for user in users + if any( + query in str(user.get(field, "") or "").lower() + for field in ( + "displayName", + "userPrincipalName", + "mail", + "department", + "jobTitle", + "givenName", + "surname", + ) + ) + ] + + users.sort(key=lambda item: (item["userPrincipalName"] or "").lower()) + return users, total_before_search + + def _serialize_user(self, user: dict[str, Any], sku_lookup: dict[str, str] | None = None) -> dict[str, Any]: + assigned_license_ids = [ + item.get("skuId") + for item in (user.get("assignedLicenses") or []) + if item.get("skuId") + ] + license_labels = [sku_lookup.get(item, item) for item in assigned_license_ids] if sku_lookup else [] + return { + "id": user.get("id"), + "displayName": user.get("displayName") or "", + "userPrincipalName": user.get("userPrincipalName") or "", + "mail": user.get("mail") or "", + "givenName": user.get("givenName") or "", + "surname": user.get("surname") or "", + "department": user.get("department") or "", + "jobTitle": user.get("jobTitle") or "", + "officeLocation": user.get("officeLocation") or "", + "mobilePhone": user.get("mobilePhone") or "", + "usageLocation": user.get("usageLocation") or "", + "accountEnabled": bool(user.get("accountEnabled", True)), + "assignedLicenses": assigned_license_ids, + "assignedLicensesCount": len(assigned_license_ids), + "licenseLabels": license_labels, + "createdDateTime": user.get("createdDateTime") or "", + } + + def _normalize_identifier(self, identifier: str) -> str: + normalized = str(identifier).strip() + if not normalized: + raise ValueError("账号标识不能为空。") + if "@" in normalized: + return normalized + if self.settings.default_domain: + return f"{normalized}@{self.settings.default_domain}" + return normalized + + def _resolve_identifier(self, payload: dict[str, Any], required: bool = False) -> str: + value = self._string_value(payload, IDENTIFIER_ALIASES) + if value: + return value + if required: + raise ValueError("缺少账号标识字段,至少需要 userPrincipalName / user_id / username / email 之一。") + return "" + + def _raw_value(self, payload: dict[str, Any], aliases: list[str]) -> Any: + normalized_payload = {self._normalize_key(key): value for key, value in payload.items()} + for alias in aliases: + normalized_alias = self._normalize_key(alias) + if normalized_alias in normalized_payload: + return normalized_payload[normalized_alias] + return None + + def _string_value(self, payload: dict[str, Any], aliases: list[str]) -> str: + value = self._raw_value(payload, aliases) + if value is None: + return "" + if isinstance(value, str): + return value.strip() + return str(value).strip() + + def _bool_value(self, payload: dict[str, Any], aliases: list[str], default: bool) -> bool: + value = self._raw_value(payload, aliases) + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + normalized = str(value).strip().lower() + if normalized in {"1", "true", "yes", "y", "enabled", "on"}: + return True + if normalized in {"0", "false", "no", "n", "disabled", "off"}: + return False + return default + + @staticmethod + def _normalize_key(key: str) -> str: + return "".join(ch for ch in str(key).lower() if ch.isalnum()) + + @staticmethod + def _translate_graph_error(exc: GraphAPIError, fallback_message: str) -> ServiceOperationError: + message = fallback_message + if exc.message: + message = f"{fallback_message}: {exc.message}" + status_code = exc.status_code or 502 + lowered = message.lower() + if "already exists" in lowered or "another object with the same value" in lowered: + status_code = 409 + return ServiceOperationError(message=message, status_code=status_code, details=exc.response) diff --git a/office365_admin/settings.py b/office365_admin/settings.py new file mode 100644 index 0000000..9cd4529 --- /dev/null +++ b/office365_admin/settings.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass, field + +from dotenv import load_dotenv + + +GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0" +GRAPH_SCOPE = "https://graph.microsoft.com/.default" +TOKEN_ENDPOINT_TEMPLATE = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" + + +def _env_bool(name: str, default: bool = False) -> bool: + return os.getenv(name, str(default)).strip().lower() in {"1", "true", "yes", "on"} + + +def _env_int(name: str, default: int) -> int: + try: + return int(os.getenv(name, str(default)).strip()) + except ValueError: + return default + + +@dataclass +class Settings: + app_name: str + host: str + port: int + debug: bool + session_secret: str + auth_enabled: bool + admin_username: str + admin_password: str + client_id: str + tenant_id: str + client_secret: str + default_password: str + default_domain: str + default_usage_location: str + default_license_sku: str + force_change_password: bool + graph_base_url: str + token_endpoint: str + scope: str + default_page_size: int = 25 + max_page_size: int = 100 + validation_errors: tuple[str, ...] = field(default_factory=tuple) + warnings: tuple[str, ...] = field(default_factory=tuple) + + @property + def graph_ready(self) -> bool: + return not self.validation_errors + + @property + def effective_auth_enabled(self) -> bool: + return self.auth_enabled and bool(self.admin_username and self.admin_password) + + def to_public_dict(self) -> dict: + return { + "appName": self.app_name, + "graphFlavor": "Microsoft Graph Global", + "graphReady": self.graph_ready, + "validationErrors": list(self.validation_errors), + "warnings": list(self.warnings), + "authEnabled": self.effective_auth_enabled, + "defaultDomain": self.default_domain, + "defaultUsageLocation": self.default_usage_location, + "defaultLicenseSku": self.default_license_sku, + "forceChangePassword": self.force_change_password, + "pageSize": self.default_page_size, + "maxPageSize": self.max_page_size, + } + + +def load_settings() -> Settings: + load_dotenv() + + tenant_id = os.getenv("TENANT_ID", "").strip() + graph_base_url = GRAPH_BASE_URL + token_endpoint = TOKEN_ENDPOINT_TEMPLATE.format(tenant_id=tenant_id) if tenant_id else "" + scope = GRAPH_SCOPE + + validation_errors: list[str] = [] + warnings: list[str] = [] + + required_fields = { + "CLIENT_ID": os.getenv("CLIENT_ID", "").strip(), + "TENANT_ID": tenant_id, + "CLIENT_SECRET": os.getenv("CLIENT_SECRET", "").strip(), + "DEFAULT_PASSWORD": os.getenv("DEFAULT_PASSWORD", "").strip(), + } + + for field_name, value in required_fields.items(): + if not value: + validation_errors.append(f"{field_name} 未配置") + + if not os.getenv("DEFAULT_DOMAIN", "").strip(): + warnings.append("DEFAULT_DOMAIN 未配置,创建账号时必须填写完整 userPrincipalName。") + + auth_enabled = _env_bool("WEB_AUTH_ENABLED", True) + admin_username = os.getenv("ADMIN_USERNAME", "").strip() + admin_password = os.getenv("ADMIN_PASSWORD", "").strip() + if auth_enabled and not (admin_username and admin_password): + warnings.append("WEB_AUTH_ENABLED=true 但未配置后台登录账号,已自动退回为无登录保护模式。") + + return Settings( + app_name=os.getenv("APP_NAME", "Office 365 User Management Platform").strip(), + host=os.getenv("HOST", "0.0.0.0").strip(), + port=_env_int("PORT", 8000), + debug=_env_bool("DEBUG", False), + session_secret=os.getenv("SESSION_SECRET", "office365-admin-dev-secret").strip(), + auth_enabled=auth_enabled, + admin_username=admin_username, + admin_password=admin_password, + client_id=required_fields["CLIENT_ID"], + tenant_id=required_fields["TENANT_ID"], + client_secret=required_fields["CLIENT_SECRET"], + default_password=required_fields["DEFAULT_PASSWORD"], + default_domain=os.getenv("DEFAULT_DOMAIN", "").strip(), + default_usage_location=os.getenv("DEFAULT_USAGE_LOCATION", "US").strip() or "US", + default_license_sku=os.getenv("DEFAULT_LICENSE_SKU", "").strip(), + force_change_password=_env_bool("FORCE_CHANGE_PASSWORD", True), + graph_base_url=graph_base_url, + token_endpoint=token_endpoint, + scope=scope, + default_page_size=min(max(_env_int("DEFAULT_PAGE_SIZE", 25), 1), 100), + max_page_size=min(max(_env_int("MAX_PAGE_SIZE", 100), 10), 500), + validation_errors=tuple(validation_errors), + warnings=tuple(warnings), + ) diff --git a/office365_admin/static/app.js b/office365_admin/static/app.js new file mode 100644 index 0000000..6e714ad --- /dev/null +++ b/office365_admin/static/app.js @@ -0,0 +1,839 @@ +const state = { + bootstrap: window.APP_BOOTSTRAP || {}, + authenticated: false, + users: [], + page: 1, + pageSize: (window.APP_BOOTSTRAP && window.APP_BOOTSTRAP.pageSize) || 25, + total: 0, + totalBeforeSearch: 0, + summary: { active: 0, disabled: 0 }, + search: "", + licenses: [], + selectedUser: null, + selectedUsers: new Set(), + activeTaskId: "", + activeTaskTimer: null, +}; + +const elements = { + platformStatus: document.getElementById("platform-status"), + platformSubstatus: document.getElementById("platform-substatus"), + metricTotal: document.getElementById("metric-total"), + metricActive: document.getElementById("metric-active"), + metricDisabled: document.getElementById("metric-disabled"), + metricLicense: document.getElementById("metric-license"), + usersTableBody: document.getElementById("users-table-body"), + paginationInfo: document.getElementById("pagination-info"), + licenseList: document.getElementById("license-list"), + resultConsole: document.getElementById("result-console"), + loginSection: document.getElementById("login-section"), + loginForm: document.getElementById("login-form"), + logoutBtn: document.getElementById("logout-btn"), + refreshAllBtn: document.getElementById("refresh-all-btn"), + searchForm: document.getElementById("search-form"), + searchInput: document.getElementById("search-input"), + prevPageBtn: document.getElementById("prev-page-btn"), + nextPageBtn: document.getElementById("next-page-btn"), + userForm: document.getElementById("user-form"), + selectedUserId: document.getElementById("selected-user-id"), + deleteUserBtn: document.getElementById("delete-user-btn"), + newUserBtn: document.getElementById("new-user-btn"), + clearFormBtn: document.getElementById("clear-form-btn"), + resetPasswordBtn: document.getElementById("reset-password-btn"), + selectedCount: document.getElementById("selected-count"), + selectAllResultsBtn: document.getElementById("select-all-results-btn"), + clearSelectionBtn: document.getElementById("clear-selection-btn"), + bulkEnableBtn: document.getElementById("bulk-enable-btn"), + bulkDisableBtn: document.getElementById("bulk-disable-btn"), + bulkResetBtn: document.getElementById("bulk-reset-btn"), + bulkDeleteBtn: document.getElementById("bulk-delete-btn"), + selectPageCheckbox: document.getElementById("select-page-checkbox"), + taskStatusCard: document.getElementById("task-status-card"), + taskStatusTitle: document.getElementById("task-status-title"), + taskStatusState: document.getElementById("task-status-state"), + taskProgressFill: document.getElementById("task-progress-fill"), + taskStatusText: document.getElementById("task-status-text"), +}; + +const TASK_LABELS = { + "create": "批量创建", + "update": "批量更新", + "delete": "批量删除", + "reset-password": "批量改密", +}; + +async function api(path, options = {}) { + const config = { ...options }; + config.headers = config.headers || {}; + + if (config.body && !(config.body instanceof FormData)) { + config.headers["Content-Type"] = "application/json"; + } + + const response = await fetch(path, config); + const payload = await response.json().catch(() => ({ + success: false, + message: "接口返回了无法解析的响应。", + })); + + if (response.status === 401) { + state.authenticated = false; + updateAuthView(); + } + + if (!response.ok || !payload.success) { + throw new Error(payload.message || "请求失败"); + } + return payload.data; +} + +function updatePlatformStatus() { + if (state.bootstrap.graphReady) { + elements.platformStatus.textContent = "Graph 已就绪"; + elements.platformSubstatus.textContent = state.bootstrap.graphFlavor || "Microsoft Graph Global"; + } else { + elements.platformStatus.textContent = "等待配置"; + const errors = state.bootstrap.validationErrors || []; + elements.platformSubstatus.textContent = errors.join(";") || "请补充 .env"; + } +} + +function updateAuthView() { + const needsLogin = state.bootstrap.authEnabled && !state.authenticated; + elements.loginSection.classList.toggle("hidden", !needsLogin); +} + +function setConsole(content) { + elements.resultConsole.textContent = content; +} + +function appendConsole(title, summary) { + const lines = [ + `[${new Date().toLocaleString("zh-CN")}] ${title}`, + String(summary || ""), + "", + ]; + elements.resultConsole.textContent = lines.join("\n") + elements.resultConsole.textContent; +} + +function formatTaskLabel(operation) { + return TASK_LABELS[operation] || operation; +} + +function formatTaskState(task) { + if (task.status === "queued") { + return "已提交"; + } + if (task.status === "running") { + return "执行中"; + } + if (task.status === "succeeded") { + return "已完成"; + } + if (task.status === "failed") { + return "失败"; + } + return task.status || "未知"; +} + +function summarizeTask(task) { + const base = `${formatTaskLabel(task.operation)}:${task.completed}/${task.total},成功 ${task.successCount},失败 ${task.failureCount}`; + if (task.failureCount > 0) { + return `${base}。详细失败项请查看 logs/office365_admin.log`; + } + return base; +} + +function showTaskCard(task) { + if (!elements.taskStatusCard) { + return; + } + elements.taskStatusCard.classList.remove("hidden"); + elements.taskStatusTitle.textContent = formatTaskLabel(task.operation); + elements.taskStatusState.textContent = formatTaskState(task); + elements.taskProgressFill.style.width = `${task.progressPercent || 0}%`; + const statusText = [ + `进度 ${task.completed || 0} / ${task.total || 0}`, + `成功 ${task.successCount || 0}`, + `失败 ${task.failureCount || 0}`, + ].join(" · "); + const currentText = task.currentItem + ? ` · 当前 ${task.currentItem}` + : ""; + elements.taskStatusText.textContent = `${statusText}${currentText}`; +} + +function showSingleActionStatus(title, text) { + if (!elements.taskStatusCard) { + return; + } + elements.taskStatusCard.classList.remove("hidden"); + elements.taskStatusTitle.textContent = title; + elements.taskStatusState.textContent = "处理中"; + elements.taskProgressFill.style.width = "30%"; + elements.taskStatusText.textContent = text; +} + +function completeSingleActionStatus(title, text) { + if (!elements.taskStatusCard) { + return; + } + elements.taskStatusCard.classList.remove("hidden"); + elements.taskStatusTitle.textContent = title; + elements.taskStatusState.textContent = "已完成"; + elements.taskProgressFill.style.width = "100%"; + elements.taskStatusText.textContent = text; +} + +function showFailedActionStatus(title, text) { + if (!elements.taskStatusCard) { + return; + } + elements.taskStatusCard.classList.remove("hidden"); + elements.taskStatusTitle.textContent = title; + elements.taskStatusState.textContent = "失败"; + elements.taskProgressFill.style.width = "100%"; + elements.taskStatusText.textContent = text; +} + +function clearTaskPolling() { + if (state.activeTaskTimer) { + window.clearTimeout(state.activeTaskTimer); + state.activeTaskTimer = null; + } +} + +async function pollTask(taskId) { + try { + const task = await api(`/api/tasks/${encodeURIComponent(taskId)}`); + showTaskCard(task); + + if (task.status === "queued" || task.status === "running") { + clearTaskPolling(); + state.activeTaskTimer = window.setTimeout(() => pollTask(taskId), 1000); + return; + } + + state.activeTaskId = ""; + clearTaskPolling(); + appendConsole( + task.status === "failed" ? `${formatTaskLabel(task.operation)}失败` : `${formatTaskLabel(task.operation)}完成`, + summarizeTask(task), + ); + await refreshAll(); + } catch (error) { + state.activeTaskId = ""; + clearTaskPolling(); + showFailedActionStatus("任务轮询失败", error.message); + appendConsole("任务轮询失败", error.message); + } +} + +function startTask(task, submittedMessage) { + state.activeTaskId = task.id; + clearTaskPolling(); + showTaskCard(task); + appendConsole("任务已提交", submittedMessage); + pollTask(task.id); +} + +function statusPill(enabled) { + const klass = enabled ? "active" : "disabled"; + const label = enabled ? "启用" : "停用"; + return `${label}`; +} + +function selectedIdentifiers() { + return Array.from(state.selectedUsers); +} + +function updateSelectionUi() { + const count = state.selectedUsers.size; + if (elements.selectedCount) { + elements.selectedCount.textContent = count ? `已选择 ${count} 个账号` : "未选择账号"; + } + + const currentPageIds = state.users.map((user) => user.userPrincipalName).filter(Boolean); + const selectedOnPage = currentPageIds.filter((identifier) => state.selectedUsers.has(identifier)).length; + + if (elements.selectPageCheckbox) { + elements.selectPageCheckbox.checked = currentPageIds.length > 0 && selectedOnPage === currentPageIds.length; + elements.selectPageCheckbox.indeterminate = selectedOnPage > 0 && selectedOnPage < currentPageIds.length; + elements.selectPageCheckbox.disabled = currentPageIds.length === 0; + } + + [ + elements.clearSelectionBtn, + elements.bulkEnableBtn, + elements.bulkDisableBtn, + elements.bulkResetBtn, + elements.bulkDeleteBtn, + ].forEach((button) => { + if (button) { + button.disabled = count === 0; + } + }); + + if (elements.selectAllResultsBtn) { + elements.selectAllResultsBtn.disabled = state.total === 0; + } +} + +function renderUsers() { + if (!state.users.length) { + elements.usersTableBody.innerHTML = `没有匹配到用户`; + updateSelectionUi(); + return; + } + + elements.usersTableBody.innerHTML = state.users.map((user) => { + const identifier = user.userPrincipalName || ""; + const checked = state.selectedUsers.has(identifier) ? "checked" : ""; + return ` + + + + + ${escapeHtml(user.displayName || "-")} + ${escapeHtml(identifier || "-")} + +
${escapeHtml(user.department || "-")}
+
${escapeHtml(user.jobTitle || "-")}
+ + ${statusPill(user.accountEnabled)} + ${user.assignedLicensesCount || 0} + +
+ + + +
+ + + `; + }).join(""); + + elements.paginationInfo.textContent = `第 ${state.page} 页,当前 ${state.users.length} / 共 ${state.total} 条`; + updateSelectionUi(); +} + +function renderMetrics() { + elements.metricTotal.textContent = String(state.total); + elements.metricActive.textContent = String(state.summary.active || 0); + elements.metricDisabled.textContent = String(state.summary.disabled || 0); + const available = state.licenses.reduce((sum, item) => sum + (item.availableUnits || 0), 0); + elements.metricLicense.textContent = String(available); + const totalFoot = state.search + ? `搜索结果 ${state.total} / 全量 ${state.totalBeforeSearch}` + : `全量账号 ${state.totalBeforeSearch}`; + document.getElementById("metric-total-foot").textContent = totalFoot; +} + +function renderLicenses() { + if (!state.licenses.length) { + elements.licenseList.innerHTML = `
未获取到许可证信息
`; + return; + } + + elements.licenseList.innerHTML = state.licenses.map((item) => ` +
+ ${escapeHtml(item.skuPartNumber || "UNKNOWN")} +
可用席位:${item.availableUnits}
+
已用席位:${item.consumedUnits}
+
总席位:${item.totalUnits}
+
+ `).join(""); +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +async function fetchSession() { + const data = await api("/api/session"); + state.authenticated = data.authenticated; + updateAuthView(); +} + +async function fetchUsers() { + const query = new URLSearchParams({ + page: String(state.page), + pageSize: String(state.pageSize), + search: state.search, + }); + const data = await api(`/api/users?${query.toString()}`); + state.users = data.items; + state.total = data.total; + state.totalBeforeSearch = data.totalBeforeSearch; + state.summary = data.summary; + renderUsers(); + renderMetrics(); +} + +async function fetchLicenses() { + const data = await api("/api/licenses"); + state.licenses = data; + renderLicenses(); + renderMetrics(); +} + +async function fetchUserDetail(identifier) { + const data = await api(`/api/users/${encodeURIComponent(identifier)}`); + state.selectedUser = data; + fillUserForm(data); + appendConsole("已加载用户", data.userPrincipalName || identifier); +} + +function formPayload() { + return { + userPrincipalName: document.getElementById("userPrincipalName").value.trim(), + displayName: document.getElementById("displayName").value.trim(), + givenName: document.getElementById("givenName").value.trim(), + surname: document.getElementById("surname").value.trim(), + department: document.getElementById("department").value.trim(), + jobTitle: document.getElementById("jobTitle").value.trim(), + officeLocation: document.getElementById("officeLocation").value.trim(), + mobilePhone: document.getElementById("mobilePhone").value.trim(), + usageLocation: document.getElementById("usageLocation").value.trim(), + skuPartNumber: document.getElementById("skuPartNumber").value.trim(), + password: document.getElementById("password").value, + accountEnabled: document.getElementById("accountEnabled").checked, + forceChangePasswordNextSignIn: document.getElementById("forceChangePasswordNextSignIn").checked, + }; +} + +function fillUserForm(user) { + elements.selectedUserId.value = user.id || ""; + document.getElementById("userPrincipalName").value = user.userPrincipalName || ""; + document.getElementById("displayName").value = user.displayName || ""; + document.getElementById("givenName").value = user.givenName || ""; + document.getElementById("surname").value = user.surname || ""; + document.getElementById("department").value = user.department || ""; + document.getElementById("jobTitle").value = user.jobTitle || ""; + document.getElementById("officeLocation").value = user.officeLocation || ""; + document.getElementById("mobilePhone").value = user.mobilePhone || ""; + document.getElementById("usageLocation").value = user.usageLocation || state.bootstrap.defaultUsageLocation || ""; + document.getElementById("skuPartNumber").value = (user.licenseLabels && user.licenseLabels[0]) || ""; + document.getElementById("password").value = ""; + document.getElementById("accountEnabled").checked = Boolean(user.accountEnabled); + document.getElementById("forceChangePasswordNextSignIn").checked = state.bootstrap.forceChangePassword; +} + +function clearUserForm() { + state.selectedUser = null; + elements.selectedUserId.value = ""; + elements.userForm.reset(); + document.getElementById("accountEnabled").checked = true; + document.getElementById("forceChangePasswordNextSignIn").checked = state.bootstrap.forceChangePassword; + document.getElementById("usageLocation").value = state.bootstrap.defaultUsageLocation || ""; +} + +function toggleUserSelection(identifier, checked) { + if (!identifier) { + return; + } + if (checked) { + state.selectedUsers.add(identifier); + } else { + state.selectedUsers.delete(identifier); + } + updateSelectionUi(); +} + +function clearSelection() { + state.selectedUsers.clear(); + updateSelectionUi(); + renderUsers(); +} + +function toggleCurrentPageSelection(checked) { + state.users.forEach((user) => { + if (!user.userPrincipalName) { + return; + } + if (checked) { + state.selectedUsers.add(user.userPrincipalName); + } else { + state.selectedUsers.delete(user.userPrincipalName); + } + }); + renderUsers(); +} + +async function selectAllMatchingUsers() { + const query = new URLSearchParams({ search: state.search }); + const data = await api(`/api/users/selection?${query.toString()}`); + state.selectedUsers = new Set(data.identifiers || []); + renderUsers(); + appendConsole("已选中搜索结果", `共 ${state.selectedUsers.size} 个账号`); +} + +async function saveUser(event) { + event.preventDefault(); + const payload = formPayload(); + const identifier = payload.userPrincipalName; + if (!identifier) { + throw new Error("请先填写账号或邮箱。"); + } + + showSingleActionStatus("保存用户", identifier); + + let data; + if (state.selectedUser && state.selectedUser.userPrincipalName) { + data = await api(`/api/users/${encodeURIComponent(state.selectedUser.userPrincipalName)}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); + appendConsole("用户已更新", data.user.userPrincipalName || identifier); + } else { + data = await api("/api/users", { + method: "POST", + body: JSON.stringify(payload), + }); + appendConsole("用户已创建", data.user.userPrincipalName || identifier); + } + + completeSingleActionStatus("保存用户", data.user.userPrincipalName || identifier); + await refreshAll(); + if (data.user && data.user.userPrincipalName) { + await fetchUserDetail(data.user.userPrincipalName); + } +} + +async function deleteCurrentUser(identifier) { + if (!identifier) { + throw new Error("请先选择一个用户。"); + } + const confirmDelete = window.confirm(`确认删除 ${identifier} 吗?此操作不可撤销。`); + if (!confirmDelete) { + return; + } + + showSingleActionStatus("删除用户", identifier); + await api(`/api/users/${encodeURIComponent(identifier)}`, { method: "DELETE" }); + state.selectedUsers.delete(identifier); + appendConsole("用户已删除", identifier); + completeSingleActionStatus("删除用户", identifier); + clearUserForm(); + await refreshAll(); +} + +async function resetPassword(identifier) { + if (!identifier) { + throw new Error("请先选择一个用户。"); + } + const payload = { + password: document.getElementById("password").value, + forceChangePasswordNextSignIn: document.getElementById("forceChangePasswordNextSignIn").checked, + }; + showSingleActionStatus("重置密码", identifier); + await api(`/api/users/${encodeURIComponent(identifier)}/reset-password`, { + method: "POST", + body: JSON.stringify(payload), + }); + appendConsole("密码已重置", identifier); + completeSingleActionStatus("重置密码", identifier); +} + +async function submitBatchTask(action, body, summaryText) { + const task = await api(`/api/users/batch/${action}`, { + method: "POST", + body: JSON.stringify(body), + }); + startTask(task, summaryText); +} + +async function runSelectedBulk(action) { + const identifiers = selectedIdentifiers(); + if (!identifiers.length) { + throw new Error("请先勾选至少一个用户。"); + } + + if (action === "delete") { + const confirmed = window.confirm(`确认批量删除这 ${identifiers.length} 个账号吗?此操作不可撤销。`); + if (!confirmed) { + return; + } + await submitBatchTask("delete", { identifiers }, `批量删除任务已提交,共 ${identifiers.length} 个账号`); + } else if (action === "enable" || action === "disable") { + const rows = identifiers.map((userPrincipalName) => ({ + userPrincipalName, + accountEnabled: action === "enable", + })); + await submitBatchTask("update", { rows }, `${action === "enable" ? "批量启用" : "批量停用"}任务已提交,共 ${identifiers.length} 个账号`); + } else if (action === "reset-password") { + const promptedPassword = window.prompt("输入统一临时密码;留空则使用系统默认密码。", ""); + if (promptedPassword === null) { + return; + } + const password = promptedPassword.trim(); + const rows = identifiers.map((userPrincipalName) => ({ + userPrincipalName, + ...(password ? { password } : {}), + forceChangePasswordNextSignIn: true, + })); + await submitBatchTask("reset-password", { rows }, `批量改密任务已提交,共 ${identifiers.length} 个账号`); + } else { + throw new Error("不支持的批量操作。"); + } + + clearSelection(); +} + +async function runBatch(action) { + const textareaMap = { + "create": document.getElementById("batch-create-content"), + "update": document.getElementById("batch-update-content"), + "delete": document.getElementById("batch-delete-content"), + "reset-password": document.getElementById("batch-reset-content"), + }; + + const textarea = textareaMap[action]; + const content = textarea.value.trim(); + if (!content) { + throw new Error("请先粘贴批量内容或上传文件。"); + } + + await submitBatchTask(action, { content }, `${formatTaskLabel(action)}任务已提交,等待后台处理`); +} + +async function refreshAll() { + updatePlatformStatus(); + if (state.bootstrap.authEnabled && !state.authenticated) { + return; + } + if (!state.bootstrap.graphReady) { + return; + } + await Promise.all([fetchUsers(), fetchLicenses()]); +} + +function bindEvents() { + elements.loginForm?.addEventListener("submit", async (event) => { + event.preventDefault(); + try { + showSingleActionStatus("登录后台", "正在验证管理员账号"); + await api("/api/login", { + method: "POST", + body: JSON.stringify({ + username: document.getElementById("login-username").value.trim(), + password: document.getElementById("login-password").value, + }), + }); + await fetchSession(); + await refreshAll(); + appendConsole("登录成功", "现在可以开始管理租户账号"); + completeSingleActionStatus("登录后台", "登录成功"); + } catch (error) { + showFailedActionStatus("登录后台", error.message); + appendConsole("登录失败", error.message); + } + }); + + elements.logoutBtn?.addEventListener("click", async () => { + await api("/api/logout", { method: "POST" }); + state.authenticated = false; + updateAuthView(); + appendConsole("已退出后台", "如需继续操作,请重新登录"); + }); + + elements.refreshAllBtn?.addEventListener("click", async () => { + try { + showSingleActionStatus("刷新数据", "正在重新加载用户与许可证"); + await refreshAll(); + appendConsole("刷新完成", "用户列表和许可证信息已更新"); + completeSingleActionStatus("刷新数据", "刷新完成"); + } catch (error) { + showFailedActionStatus("刷新数据", error.message); + appendConsole("刷新失败", error.message); + } + }); + + elements.searchForm?.addEventListener("submit", async (event) => { + event.preventDefault(); + state.search = elements.searchInput.value.trim(); + state.page = 1; + try { + showSingleActionStatus("搜索用户", state.search || "全部用户"); + await fetchUsers(); + appendConsole("搜索完成", `${state.total} 条匹配结果`); + completeSingleActionStatus("搜索用户", `${state.total} 条匹配结果`); + } catch (error) { + showFailedActionStatus("搜索用户", error.message); + appendConsole("搜索失败", error.message); + } + }); + + elements.prevPageBtn?.addEventListener("click", async () => { + if (state.page <= 1) { + return; + } + state.page -= 1; + await fetchUsers(); + }); + + elements.nextPageBtn?.addEventListener("click", async () => { + if (state.page * state.pageSize >= state.total) { + return; + } + state.page += 1; + await fetchUsers(); + }); + + elements.selectPageCheckbox?.addEventListener("change", (event) => { + toggleCurrentPageSelection(event.target.checked); + }); + + elements.selectAllResultsBtn?.addEventListener("click", async () => { + try { + await selectAllMatchingUsers(); + } catch (error) { + appendConsole("全选失败", error.message); + } + }); + + elements.clearSelectionBtn?.addEventListener("click", () => { + clearSelection(); + appendConsole("已清空选择", "当前未勾选任何账号"); + }); + + elements.bulkEnableBtn?.addEventListener("click", async () => { + try { + await runSelectedBulk("enable"); + } catch (error) { + appendConsole("批量启用失败", error.message); + } + }); + + elements.bulkDisableBtn?.addEventListener("click", async () => { + try { + await runSelectedBulk("disable"); + } catch (error) { + appendConsole("批量停用失败", error.message); + } + }); + + elements.bulkResetBtn?.addEventListener("click", async () => { + try { + await runSelectedBulk("reset-password"); + } catch (error) { + appendConsole("批量改密失败", error.message); + } + }); + + elements.bulkDeleteBtn?.addEventListener("click", async () => { + try { + await runSelectedBulk("delete"); + } catch (error) { + appendConsole("批量删除失败", error.message); + } + }); + + elements.usersTableBody?.addEventListener("change", (event) => { + const checkbox = event.target.closest("input[data-select-identifier]"); + if (!checkbox) { + return; + } + const identifier = decodeURIComponent(checkbox.dataset.selectIdentifier || ""); + toggleUserSelection(identifier, checkbox.checked); + }); + + elements.usersTableBody?.addEventListener("click", async (event) => { + const button = event.target.closest("button[data-action]"); + if (!button) { + return; + } + const action = button.dataset.action; + const identifier = decodeURIComponent(button.dataset.identifier || ""); + try { + if (action === "view") { + await fetchUserDetail(identifier); + } else if (action === "delete") { + await deleteCurrentUser(identifier); + } else if (action === "reset") { + await resetPassword(identifier); + } + } catch (error) { + appendConsole("用户操作失败", error.message); + showFailedActionStatus("用户操作失败", error.message); + } + }); + + elements.userForm?.addEventListener("submit", async (event) => { + try { + await saveUser(event); + } catch (error) { + appendConsole("保存失败", error.message); + showFailedActionStatus("保存用户", error.message); + } + }); + + elements.newUserBtn?.addEventListener("click", () => clearUserForm()); + elements.clearFormBtn?.addEventListener("click", () => clearUserForm()); + + elements.deleteUserBtn?.addEventListener("click", async () => { + try { + await deleteCurrentUser(state.selectedUser && state.selectedUser.userPrincipalName); + } catch (error) { + appendConsole("删除失败", error.message); + showFailedActionStatus("删除用户", error.message); + } + }); + + elements.resetPasswordBtn?.addEventListener("click", async () => { + try { + await resetPassword(state.selectedUser && state.selectedUser.userPrincipalName); + } catch (error) { + appendConsole("重置密码失败", error.message); + showFailedActionStatus("重置密码", error.message); + } + }); + + document.querySelectorAll("[data-batch-action]").forEach((button) => { + button.addEventListener("click", async () => { + try { + await runBatch(button.dataset.batchAction); + } catch (error) { + appendConsole("批量任务提交失败", error.message); + showFailedActionStatus("批量任务提交失败", error.message); + } + }); + }); + + document.querySelectorAll(".file-input").forEach((input) => { + input.addEventListener("change", async (event) => { + const file = event.target.files && event.target.files[0]; + if (!file) { + return; + } + const targetId = event.target.dataset.target; + const target = document.getElementById(targetId); + target.value = await file.text(); + appendConsole("文件已加载", `${file.name} 已写入输入框`); + }); + }); +} + +async function boot() { + setConsole("等待操作..."); + updatePlatformStatus(); + bindEvents(); + updateSelectionUi(); + + try { + await fetchSession(); + if (!state.bootstrap.authEnabled || state.authenticated) { + await refreshAll(); + } + } catch (error) { + appendConsole("初始化失败", error.message); + showFailedActionStatus("初始化失败", error.message); + } +} + +boot(); diff --git a/office365_admin/static/examples/batch-create-template.csv b/office365_admin/static/examples/batch-create-template.csv new file mode 100644 index 0000000..1533bec --- /dev/null +++ b/office365_admin/static/examples/batch-create-template.csv @@ -0,0 +1,3 @@ +userPrincipalName,displayName,givenName,surname,department,jobTitle,usageLocation,skuPartNumber,password +alice,Alice Zhang,Alice,Zhang,Sales,Manager,US,ENTERPRISEPACK,Temp123!2026 +bob@contoso.com,Bob Li,Bob,Li,IT,Engineer,US,ENTERPRISEPACK, diff --git a/office365_admin/static/examples/batch-delete-template.csv b/office365_admin/static/examples/batch-delete-template.csv new file mode 100644 index 0000000..776ff66 --- /dev/null +++ b/office365_admin/static/examples/batch-delete-template.csv @@ -0,0 +1,3 @@ +userPrincipalName +alice@contoso.com +bob@contoso.com diff --git a/office365_admin/static/examples/batch-reset-password-template.csv b/office365_admin/static/examples/batch-reset-password-template.csv new file mode 100644 index 0000000..580fb12 --- /dev/null +++ b/office365_admin/static/examples/batch-reset-password-template.csv @@ -0,0 +1,3 @@ +userPrincipalName,password,forceChangePasswordNextSignIn +alice@contoso.com,Temp123!2026,true +bob@contoso.com,Another123!2026,true diff --git a/office365_admin/static/examples/batch-update-template.csv b/office365_admin/static/examples/batch-update-template.csv new file mode 100644 index 0000000..36b99ea --- /dev/null +++ b/office365_admin/static/examples/batch-update-template.csv @@ -0,0 +1,3 @@ +userPrincipalName,department,jobTitle,officeLocation,mobilePhone,accountEnabled,skuPartNumber +alice@contoso.com,Operations,Lead,New York,+15550000001,true,ENTERPRISEPACK +bob@contoso.com,Finance,Analyst,Seattle,,false, diff --git a/office365_admin/static/styles.css b/office365_admin/static/styles.css new file mode 100644 index 0000000..2ee367b --- /dev/null +++ b/office365_admin/static/styles.css @@ -0,0 +1,542 @@ +:root { + --bg: #f6f1e8; + --panel: rgba(255, 250, 244, 0.82); + --panel-strong: rgba(255, 252, 248, 0.94); + --text: #112238; + --muted: #5f6f80; + --line: rgba(17, 34, 56, 0.12); + --brand: #0f6c7b; + --brand-strong: #0a425a; + --accent: #c65d3a; + --danger: #a33a2b; + --success: #217253; + --shadow: 0 20px 50px rgba(17, 34, 56, 0.12); + --radius: 24px; + --radius-sm: 16px; + --font-sans: "Avenir Next", "Trebuchet MS", "PingFang SC", "Microsoft YaHei", sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: var(--font-sans); + color: var(--text); + background: + radial-gradient(circle at top left, rgba(15, 108, 123, 0.18), transparent 30%), + linear-gradient(135deg, #f8f4ec 0%, #f0ebe3 45%, #e2edf1 100%); +} + +button, +input, +textarea { + font: inherit; +} + +.background-glow { + position: fixed; + inset: auto; + width: 28rem; + height: 28rem; + border-radius: 999px; + filter: blur(70px); + opacity: 0.32; + pointer-events: none; +} + +.glow-a { + top: -8rem; + right: -10rem; + background: #f4a261; +} + +.glow-b { + left: -8rem; + bottom: -12rem; + background: #4da6b3; +} + +.shell { + position: relative; + max-width: 1500px; + margin: 0 auto; + padding: 36px 24px 48px; +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 1.8fr) minmax(280px, 0.8fr); + gap: 24px; + align-items: stretch; + margin-bottom: 24px; +} + +.hero-copy, +.hero-side, +.panel, +.metric-card { + background: var(--panel); + backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.5); + box-shadow: var(--shadow); +} + +.hero-copy { + padding: 28px; + border-radius: calc(var(--radius) + 4px); +} + +.hero-copy h1 { + margin: 10px 0 12px; + font-size: clamp(2rem, 3vw, 3.4rem); + line-height: 1.05; +} + +.eyebrow { + margin: 0; + font-size: 0.78rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--brand); +} + +.hero-text { + margin: 0; + max-width: 48rem; + font-size: 1.02rem; + line-height: 1.7; + color: var(--muted); +} + +.hero-side { + padding: 24px; + border-radius: calc(var(--radius) + 4px); + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 18px; +} + +.status-card { + display: grid; + gap: 8px; + padding: 18px; + border-radius: var(--radius-sm); + background: linear-gradient(145deg, rgba(15, 108, 123, 0.09), rgba(198, 93, 58, 0.06)); +} + +.status-card strong { + font-size: 1.1rem; +} + +.status-label, +.muted { + color: var(--muted); +} + +.hero-actions, +.editor-actions, +.pager, +.form-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.dashboard { + display: grid; + gap: 24px; +} + +.metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; +} + +.metric-card { + border-radius: 22px; + padding: 20px; + display: grid; + gap: 8px; +} + +.metric-label { + color: var(--muted); + font-size: 0.92rem; +} + +.metric-card strong { + font-size: clamp(1.8rem, 4vw, 2.6rem); +} + +.metric-foot { + color: var(--muted); + font-size: 0.86rem; +} + +.panel { + border-radius: var(--radius); + padding: 24px; +} + +.panel-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 18px; +} + +.panel-head h2, +.batch-card h3 { + margin: 0 0 8px; +} + +.panel-head p, +.batch-card p { + margin: 0; + color: var(--muted); + line-height: 1.65; +} + +.search-form, +.inline-form { + display: flex; + gap: 12px; + flex-wrap: wrap; + align-items: end; +} + +.search-form input, +.inline-form input, +.user-form input, +.batch-card textarea { + width: 100%; + border: 1px solid var(--line); + border-radius: 14px; + padding: 12px 14px; + background: rgba(255, 255, 255, 0.72); + color: var(--text); +} + +.inline-form label, +.user-form label { + display: grid; + gap: 8px; + color: var(--muted); +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: 999px; + padding: 11px 18px; + cursor: pointer; + text-decoration: none; + transition: transform 0.16s ease, opacity 0.16s ease, background 0.16s ease; +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn:disabled { + opacity: 0.48; + cursor: not-allowed; + transform: none; +} + +.btn-primary { + background: linear-gradient(135deg, var(--brand), var(--brand-strong)); + color: #fff; +} + +.btn-secondary { + background: #d7ecef; + color: var(--brand-strong); +} + +.btn-ghost { + background: rgba(17, 34, 56, 0.08); + color: var(--text); +} + +.btn-danger { + background: linear-gradient(135deg, var(--danger), #7d241d); + color: #fff; +} + +.table-wrap { + overflow: auto; + border-radius: var(--radius-sm); + border: 1px solid var(--line); +} + +.selection-toolbar { + margin-bottom: 18px; + padding: 14px 16px; + border-radius: 18px; + border: 1px solid var(--line); + background: var(--panel-strong); + display: flex; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.selection-meta, +.selection-actions, +.table-checkbox, +.batch-card-top { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.batch-card-top { + justify-content: space-between; + align-items: flex-start; +} + +.table-checkbox { + color: var(--muted); + font-size: 0.82rem; +} + +.check-col, +.check-cell { + width: 76px; +} + +.check-cell { + text-align: center; +} + +table { + width: 100%; + border-collapse: collapse; + min-width: 980px; +} + +th, +td { + padding: 14px 16px; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: top; +} + +th { + font-size: 0.84rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +tbody tr:hover { + background: rgba(15, 108, 123, 0.05); +} + +.table-footer { + margin-top: 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.status-pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 999px; + font-size: 0.82rem; +} + +.status-pill.active { + background: rgba(33, 114, 83, 0.14); + color: var(--success); +} + +.status-pill.disabled { + background: rgba(163, 58, 43, 0.12); + color: var(--danger); +} + +.actions-inline { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.actions-inline .btn { + padding: 8px 12px; + font-size: 0.88rem; +} + +.user-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px 16px; +} + +.checkbox { + display: flex; + align-items: center; + gap: 10px; +} + +.checkbox input { + width: auto; +} + +.form-actions { + grid-column: 1 / -1; + padding-top: 8px; +} + +.license-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; +} + +.license-card, +.empty-card { + padding: 16px 18px; + border-radius: 18px; + background: var(--panel-strong); + border: 1px solid var(--line); +} + +.license-card strong { + display: block; + margin-bottom: 10px; +} + +.batch-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.batch-card { + padding: 18px; + border-radius: 20px; + background: var(--panel-strong); + border: 1px solid var(--line); + display: grid; + gap: 12px; +} + +.file-input { + width: 100%; +} + +.batch-card textarea { + min-height: 180px; + resize: vertical; +} + +.console { + margin: 0; + padding: 18px; + min-height: 220px; + border-radius: 18px; + background: #102338; + color: #edf6ff; + overflow: auto; + white-space: pre-wrap; + line-height: 1.6; +} + +.task-status-card { + margin-bottom: 14px; + padding: 16px 18px; + border-radius: 18px; + background: var(--panel-strong); + border: 1px solid var(--line); + display: grid; + gap: 10px; +} + +.task-status-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.task-progress-track { + width: 100%; + height: 10px; + border-radius: 999px; + overflow: hidden; + background: rgba(17, 34, 56, 0.1); +} + +.task-progress-fill { + width: 0%; + height: 100%; + background: linear-gradient(135deg, var(--brand), var(--accent)); + transition: width 0.2s ease; +} + +.empty-cell, +.hidden { + display: none; +} + +.tag { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border-radius: 999px; + background: rgba(15, 108, 123, 0.09); + color: var(--brand-strong); + font-size: 0.82rem; +} + +@media (max-width: 1100px) { + .hero, + .metrics, + .batch-grid, + .user-form { + grid-template-columns: 1fr; + } + + .panel-head, + .table-footer, + .selection-toolbar, + .batch-card-top { + flex-direction: column; + align-items: stretch; + } + + .search-form input { + min-width: 100%; + } +} + +@media (max-width: 720px) { + .shell { + padding: 18px 14px 28px; + } + + .panel, + .hero-copy, + .hero-side { + padding: 18px; + border-radius: 20px; + } +} diff --git a/office365_admin/tasks.py b/office365_admin/tasks.py new file mode 100644 index 0000000..20bc470 --- /dev/null +++ b/office365_admin/tasks.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import logging +import threading +import uuid +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Callable + + +logger = logging.getLogger("office365_admin.tasks") + + +def _now_iso() -> str: + return datetime.now().isoformat(timespec="seconds") + + +@dataclass +class TaskRecord: + id: str + operation: str + total: int + status: str = "queued" + message: str = "任务已提交" + created_at: str = field(default_factory=_now_iso) + started_at: str = "" + finished_at: str = "" + completed: int = 0 + success_count: int = 0 + failure_count: int = 0 + current_item: str = "" + current_message: str = "" + recent_failures: list[dict[str, str]] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + progress_percent = 0 + if self.total > 0: + progress_percent = int((self.completed / self.total) * 100) + return { + "id": self.id, + "operation": self.operation, + "status": self.status, + "message": self.message, + "createdAt": self.created_at, + "startedAt": self.started_at, + "finishedAt": self.finished_at, + "total": self.total, + "completed": self.completed, + "successCount": self.success_count, + "failureCount": self.failure_count, + "progressPercent": progress_percent, + "currentItem": self.current_item, + "currentMessage": self.current_message, + "recentFailures": self.recent_failures, + } + + +class TaskNotFoundError(KeyError): + pass + + +class BackgroundTaskManager: + def __init__(self, max_workers: int = 4): + self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="office365-task") + self._tasks: dict[str, TaskRecord] = {} + self._lock = threading.Lock() + + def submit( + self, + operation: str, + total: int, + runner: Callable[[Callable[[dict[str, Any]], None]], dict[str, Any]], + ) -> dict[str, Any]: + task_id = uuid.uuid4().hex + record = TaskRecord( + id=task_id, + operation=operation, + total=total, + message=f"{self._label(operation)}任务已提交", + ) + with self._lock: + self._tasks[task_id] = record + + logger.info("Task %s queued: operation=%s total=%s", task_id, operation, total) + self._executor.submit(self._run_task, task_id, runner) + return record.to_dict() + + def get_task(self, task_id: str) -> dict[str, Any]: + with self._lock: + record = self._tasks.get(task_id) + if record is None: + raise TaskNotFoundError(task_id) + return record.to_dict() + + def _run_task( + self, + task_id: str, + runner: Callable[[Callable[[dict[str, Any]], None]], dict[str, Any]], + ) -> None: + self._update( + task_id, + status="running", + started_at=_now_iso(), + message="任务执行中", + ) + logger.info("Task %s started", task_id) + + try: + result = runner(lambda update: self._handle_progress(task_id, update)) + summary_message = ( + f"任务完成,成功 {result.get('successCount', 0)},失败 {result.get('failureCount', 0)}" + ) + self._update( + task_id, + status="succeeded", + finished_at=_now_iso(), + completed=result.get("total", 0), + success_count=result.get("successCount", 0), + failure_count=result.get("failureCount", 0), + current_message=summary_message, + message=summary_message, + ) + logger.info("Task %s finished: %s", task_id, summary_message) + except Exception as exc: + logger.exception("Task %s failed", task_id) + self._update( + task_id, + status="failed", + finished_at=_now_iso(), + message=f"任务执行失败: {exc}", + current_message=str(exc), + ) + + def _handle_progress(self, task_id: str, update: dict[str, Any]) -> None: + update_payload = { + "completed": update.get("completed", 0), + "success_count": update.get("successCount", 0), + "failure_count": update.get("failureCount", 0), + "current_item": update.get("identifier", ""), + "current_message": update.get("message", ""), + "message": f"正在执行 {update.get('completed', 0)} / {update.get('total', 0)}", + } + if not update.get("success") and update.get("identifier"): + with self._lock: + record = self._tasks.get(task_id) + if record is not None and len(record.recent_failures) < 5: + record.recent_failures.append( + { + "identifier": update.get("identifier", ""), + "message": update.get("message", ""), + } + ) + self._update(task_id, **update_payload) + + def _update(self, task_id: str, **changes: Any) -> None: + with self._lock: + record = self._tasks.get(task_id) + if record is None: + return + for key, value in changes.items(): + setattr(record, key, value) + + @staticmethod + def _label(operation: str) -> str: + labels = { + "create": "批量创建", + "update": "批量更新", + "delete": "批量删除", + "reset-password": "批量改密", + } + return labels.get(operation, operation) diff --git a/office365_admin/templates/index.html b/office365_admin/templates/index.html new file mode 100644 index 0000000..a312015 --- /dev/null +++ b/office365_admin/templates/index.html @@ -0,0 +1,319 @@ + + + + + + Office 365 User Management Platform + + + +
+
+
+
+
+

Microsoft Graph + Web Console

+

Office 365 账号管理平台

+

+ 基于你提供的 office365-tools 能力升级而来,当前默认按国际版 Microsoft 365 / Microsoft Graph 工作,支持账号单个与批量增删改查、许可证查看、密码重置与批处理回显。 +

+
+
+
+ 平台状态 + 检测中 + 正在读取配置 +
+
+ + +
+
+
+ +
+ + +
+
+ 匹配用户数 + 0 + 当前筛选结果 +
+
+ 启用账号 + 0 + accountEnabled = true +
+
+ 停用账号 + 0 + 便于快速排查离职或冻结账号 +
+
+ 可用许可证 + 0 + 来自当前租户订阅 +
+
+ +
+
+
+

用户列表

+

支持搜索、查看详情、全选当前页或全部搜索结果,并执行批量删除 / 启停 / 改密。

+
+
+ + +
+
+ +
+
+ 未选择账号 + + +
+
+ + + + +
+
+ +
+ + + + + + + + + + + + + + + + + +
+ + 显示名登录名部门 / 职位状态许可证操作
暂无数据
+
+ + +
+ +
+
+
+

单个账号维护

+

新建账号时支持只填用户名;若配置了 DEFAULT_DOMAIN,系统会自动补全 UPN。

+
+
+ + +
+
+ +
+ + + + + + + + + + + + + + +
+ + + +
+
+
+ +
+
+
+

许可证概览

+

实时读取租户已订阅 SKU 和剩余席位。

+
+
+
+
正在加载许可证信息...
+
+
+ +
+
+
+

批量管理中心

+

支持 CSV 或 JSON。删除也支持纯文本,一行一个账号。

+
+
+ +
+
+
+
+

批量创建

+

先下载示例 CSV,编辑后再上传执行。

+
+ 下载示例 CSV +
+

CSV 表头示例:userPrincipalName,displayName,givenName,surname,department,jobTitle,usageLocation,skuPartNumber,password

+ + + +
+ +
+
+
+

批量更新

+

下载模板后只改需要更新的列,再上传执行。

+
+ 下载示例 CSV +
+

CSV 至少需要一列账号标识。空值默认忽略,不会清空原字段。

+ + + +
+ +
+
+
+

批量删除

+

可以直接下载删除模板,编辑后上传执行。

+
+ 下载示例 CSV +
+

支持 CSV/JSON,也支持纯文本,一行一个 UPN 或用户名。

+ + + +
+ +
+
+
+

批量重置密码

+

可先下载模板,编辑密码后上传执行。

+
+ 下载示例 CSV +
+

可使用纯文本账号列表,也可传入 CSV:userPrincipalName,password,forceChangePasswordNextSignIn

+ + + +
+
+
+ +
+
+
+

执行结果

+

界面只展示简要摘要,详细逐条处理信息写入服务日志。

+
+
+ +
等待操作...
+
+
+
+ + + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..94dfb95 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Flask>=3.0,<4 +python-dotenv>=1.0,<2 +requests>=2.31,<3 +pytest>=8.0,<9 + diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..fc68e63 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,184 @@ +from office365_admin import create_app +from office365_admin.settings import Settings + + +class FakeService: + def list_licenses(self): + return [{"skuId": "1", "skuPartNumber": "O365_BUSINESS", "availableUnits": 5, "consumedUnits": 5, "totalUnits": 10}] + + def list_users(self, search="", page=1, page_size=25): + return { + "items": [ + { + "id": "1", + "displayName": "Alice", + "userPrincipalName": "alice@example.com", + "mail": "alice@example.com", + "givenName": "Alice", + "surname": "Zhang", + "department": "IT", + "jobTitle": "Engineer", + "officeLocation": "", + "mobilePhone": "", + "usageLocation": "US", + "accountEnabled": True, + "assignedLicenses": ["1"], + "assignedLicensesCount": 1, + "licenseLabels": ["O365_BUSINESS"], + "createdDateTime": "", + } + ], + "page": page, + "pageSize": page_size, + "total": 1, + "totalBeforeSearch": 1, + "summary": {"active": 1, "disabled": 0}, + } + + def list_user_identifiers(self, search=""): + return {"identifiers": ["alice@example.com"], "total": 1} + + def get_user(self, identifier): + return self.list_users()["items"][0] + + def create_user(self, payload): + return {"user": self.get_user(payload["userPrincipalName"]), "temporaryPassword": "temp"} + + def update_user(self, identifier, payload): + return {"user": self.get_user(identifier)} + + def delete_user(self, identifier): + return {"user": self.get_user(identifier)} + + def reset_password(self, identifier, payload=None): + return {"user": self.get_user(identifier), "temporaryPassword": "newpass"} + + def batch_create(self, rows, progress_callback=None): + return {"operation": "create", "total": len(rows), "successCount": len(rows), "failureCount": 0, "results": []} + + def batch_update(self, rows, progress_callback=None): + return {"operation": "update", "total": len(rows), "successCount": len(rows), "failureCount": 0, "results": []} + + def batch_delete(self, identifiers, progress_callback=None): + return {"operation": "delete", "total": len(identifiers), "successCount": len(identifiers), "failureCount": 0, "results": []} + + def batch_reset_password(self, rows, progress_callback=None): + return {"operation": "reset-password", "total": len(rows), "successCount": len(rows), "failureCount": 0, "results": []} + + +class FakeTaskManager: + def __init__(self): + self.last_task = { + "id": "task-1", + "operation": "reset-password", + "status": "succeeded", + "message": "任务完成,成功 1,失败 0", + "createdAt": "2026-03-21T12:00:00", + "startedAt": "2026-03-21T12:00:00", + "finishedAt": "2026-03-21T12:00:01", + "total": 1, + "completed": 1, + "successCount": 1, + "failureCount": 0, + "progressPercent": 100, + "currentItem": "alice@example.com", + "currentMessage": "任务完成,成功 1,失败 0", + "recentFailures": [], + } + + def submit(self, operation, total, runner): + self.last_task = { + **self.last_task, + "operation": operation, + "total": total, + "message": f"{operation} submitted", + } + return self.last_task + + def get_task(self, task_id): + return self.last_task + + +def build_settings(): + return Settings( + app_name="Test App", + host="127.0.0.1", + port=8000, + debug=False, + session_secret="test-secret", + auth_enabled=True, + admin_username="admin", + admin_password="password", + client_id="client", + tenant_id="tenant", + client_secret="secret", + default_password="pass", + default_domain="example.com", + default_usage_location="US", + default_license_sku="O365_BUSINESS", + force_change_password=True, + graph_base_url="https://example.test", + token_endpoint="https://login.example.test/token", + scope="scope", + ) + + +def build_client(): + app = create_app( + settings_override=build_settings(), + service_factory=FakeService(), + task_manager_factory=FakeTaskManager(), + ) + app.config["TESTING"] = True + return app.test_client() + + +def login(client): + response = client.post("/api/login", json={"username": "admin", "password": "password"}) + assert response.status_code == 200 + + +def test_users_requires_login(): + client = build_client() + response = client.get("/api/users") + assert response.status_code == 401 + + +def test_login_and_list_users(): + client = build_client() + login(client) + response = client.get("/api/users") + assert response.status_code == 200 + payload = response.get_json() + assert payload["success"] is True + assert payload["data"]["items"][0]["userPrincipalName"] == "alice@example.com" + + +def test_list_user_identifiers_after_login(): + client = build_client() + login(client) + response = client.get("/api/users/selection") + assert response.status_code == 200 + payload = response.get_json() + assert payload["success"] is True + assert payload["data"]["identifiers"] == ["alice@example.com"] + + +def test_create_user_after_login(): + client = build_client() + login(client) + response = client.post("/api/users", json={"userPrincipalName": "alice@example.com"}) + assert response.status_code == 201 + payload = response.get_json() + assert payload["success"] is True + assert payload["data"]["temporaryPassword"] == "temp" + + +def test_batch_reset_password_submits_task(): + client = build_client() + login(client) + response = client.post("/api/users/batch/reset-password", json={"rows": [{"userPrincipalName": "alice@example.com"}]}) + assert response.status_code == 202 + payload = response.get_json() + assert payload["success"] is True + assert payload["data"]["id"] == "task-1" diff --git a/tests/test_batch.py b/tests/test_batch.py new file mode 100644 index 0000000..acfd8d5 --- /dev/null +++ b/tests/test_batch.py @@ -0,0 +1,31 @@ +from office365_admin.batch import parse_identifier_content, parse_table_content + + +def test_parse_table_content_from_csv(): + content = "userPrincipalName,displayName,department\nalice, Alice Zhang,Sales\n" + rows = parse_table_content(content) + assert rows == [ + { + "userPrincipalName": "alice", + "displayName": "Alice Zhang", + "department": "Sales", + } + ] + + +def test_parse_table_content_from_json(): + rows = parse_table_content('[{"userPrincipalName":"bob@example.com","department":"IT"}]') + assert rows[0]["userPrincipalName"] == "bob@example.com" + assert rows[0]["department"] == "IT" + + +def test_parse_identifier_content_from_lines(): + values = parse_identifier_content("alice\nbob@example.com\n") + assert values == ["alice", "bob@example.com"] + + +def test_parse_identifier_content_from_csv(): + content = "userPrincipalName,displayName\nalice@example.com,Alice\n" + values = parse_identifier_content(content) + assert values == ["alice@example.com"] +